AsyncImage and HTTP caching in iOS 27


Greetings, traveler!

When Apple introduced AsyncImage, it looked like the missing simple API for remote images in SwiftUI.

And for simple screens, it was fine. You had a URL, SwiftUI loaded the image, and you could react to loading, success, and failure states through AsyncImagePhase.

But there was always one uncomfortable detail: caching. For years, many developers treated AsyncImage as a convenient demo API rather than something they wanted to rely on in image-heavy production screens. Not because the API was useless, but because its behavior around caching was not something you could easily shape from SwiftUI.

Things look a bit different with the latest SwiftUI updates. AsyncImage now supports standard HTTP caching by default. It respects server cache headers without any extra code, and SwiftUI also gives us new ways to control image loading through URLRequest and custom URLSession configuration.

It makes AsyncImage much more useful.

What AsyncImage used to be good at

Before talking about caching, it is worth remembering what AsyncImage is.

It is not an image pipeline. It is not Nuke, Kingfisher, or SDWebImage. It is a SwiftUI view that loads and displays an image from a URL.

The simplest version still looks like this:

AsyncImage(url: imageURL)

For anything beyond that, you usually work with phases:

AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()

    case .success(let image):
        image
            .resizable()
            .scaledToFill()

    case .failure:
        Image(systemName: "photo")

    @unknown default:
        EmptyView()
    }
}

It fits SwiftUI nicely. You describe what each state should look like, and SwiftUI handles the loading lifecycle.

The problem was what happened after the image was loaded once. In a feed, grid, profile screen, or search results page, remote images constantly move through the view hierarchy as users scroll and SwiftUI updates the interface. If the image loader does not cooperate with caching, the UI can start doing unnecessary work very quickly.

iOS27: What changed in the new AsyncImage caching model

The important new behavior is this: AsyncImage now supports standard HTTP caching by default.

That means SwiftUI can use the normal URL loading system cache behavior. If the server sends cache headers that allow reuse, AsyncImage can reuse cached responses instead of treating every appearance as a fresh network load.

This matters because it makes the simple version better without changing your code.

AsyncImage(url: imageURL)

If your image server or CDN is configured correctly, this can now be enough for many ordinary cases.

That last part is important: “configured correctly.” HTTP caching depends on the response. If the server says the image can be cached, the client has something to work with. If the server sends headers that disable caching, or sends no useful caching information, AsyncImage cannot magically invent a good caching policy for your product.

So this is not “AsyncImage now has an image cache like a third-party library.” It is more specific than that. It is HTTP caching.

HTTP caching is not the same as an image pipeline

When people say “image caching,” they often mean several different things at once.

They may mean avoiding a second network request. They may mean keeping decoded images in memory. They may mean storing image files on disk. They may mean downsampling large images before display. They may mean deduplicating multiple requests for the same URL.

Those are different layers. The new AsyncImage behavior helps with the HTTP response layer. It can reuse cached URL responses according to normal HTTP rules.

That does not automatically mean you get all of this:

  • Network response cache
  • Decoded image memory cache
  • Image downsampling
  • Request coalescing
  • Prefetching
  • Priority management
  • Retry policy
  • Progressive loading
  • Custom eviction rules

Some screens do not need all that. A settings screen with a few avatars probably does not need a full image pipeline. A simple product detail page may also be fine. But large feeds, galleries, marketplace grids, or chats with media previews have very different requirements.

In those screens, caching the HTTP response is only one part of the performance story.

The new URLRequest initializer

The more interesting addition is that AsyncImage can now be created from a URLRequest.

That gives you direct control over things like cache policy and timeout interval.

For example:

var request = URLRequest(url: imageURL)
request.cachePolicy = .returnCacheDataElseLoad
request.timeoutInterval = 10

AsyncImage(request: request) { phase in
    switch phase {
    case .empty:
        ProgressView()

    case .success(let image):
        image
            .resizable()
            .scaledToFill()

    case .failure:
        Image(systemName: "photo")

    @unknown default:
        EmptyView()
    }
}

Before, AsyncImage(url:) was intentionally simple. That simplicity was nice until you needed to change loading behavior. Then you had to step outside the API and build your own loader, wrap another library, or accept the default behavior.

With URLRequest, the API has a better escape hatch. You can still use AsyncImage as a SwiftUI view, but now the network request is not completely hidden from you.

Choosing a cache policy

URLRequest.CachePolicy is where things become more explicit.

For images that rarely change, .returnCacheDataElseLoad can be useful:

let request = URLRequest(
    url: imageURL,
    cachePolicy: .returnCacheDataElseLoad
)

This tells the loading system to use cached data if it exists, and load from the network only if it does not.

That can be a good fit for immutable CDN images, stickers, icons, static covers, or content-addressed URLs where a changed image gets a changed URL.

For images that must always be checked again, you can go the other way:

var request = URLRequest(url: imageURL)
request.cachePolicy = .reloadIgnoringLocalCacheData

I would use this carefully. It is easy to fix one stale-image bug by disabling cache completely, and then quietly make the screen slower for everyone.

Usually, if an image changes often, the better fix is not “ignore cache.” The better fix is to change the URL when the image changes.

Versioned URLs are still the cleanest solution

A common example is a user avatar.

The user uploads a new avatar, but the URL stays the same:

https://example.com/users/42/avatar.png

Then the app keeps showing the old image because the cached response is still valid.

The tempting fix is to append a random value:

https://example.com/users/42/avatar.png?cacheBust=8D9A...

That works, but it also destroys caching. If you generate a new value too often, the app treats the same image as a new resource again and again.

A better approach is to use a stable version:

https://example.com/users/42/avatar.png?v=17

Or make the URL itself immutable:

https://cdn.example.com/avatars/user-42-v17.png

Now the behavior is predictable. If the avatar has not changed, the URL stays the same and cache can do its job. If the avatar changes, the URL changes too, and SwiftUI loads the new image.

That is usually the cleanest model for client code, backend code, and CDN caching.

Custom URLSession with asyncImageURLSession

The other new piece is asyncImageURLSession(_:).

This lets you provide a custom URLSession for AsyncImage views inside a view hierarchy.

A simple setup could look like this:

@Observable
final class ImageStore {
    static let imageSession: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.urlCache = URLCache(
            memoryCapacity: 64 * 1024 * 1024,
            diskCapacity: 256 * 1024 * 1024
        )
        return URLSession(configuration: configuration)
    }()
}

Then you apply that session to a subtree:

struct GalleryView: View {
    let items: [GalleryItem]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 120))]) {
                ForEach(items) { item in
                    AsyncImage(
                        request: URLRequest(
                            url: item.imageURL,
                            cachePolicy: .returnCacheDataElseLoad
                        )
                    ) { phase in
                        switch phase {
                        case .empty:
                            RoundedRectangle(cornerRadius: 12)
                                .opacity(0.1)

                        case .success(let image):
                            image
                                .resizable()
                                .scaledToFill()

                        case .failure:
                            Image(systemName: "photo")

                        @unknown default:
                            EmptyView()
                        }
                    }
                    .frame(width: 120, height: 120)
                    .clipped()
                }
            }
        }
        .asyncImageURLSession(ImageStore.imageSession)
    }
}

This is a nice SwiftUI-shaped API. The view hierarchy decides which session its images should use.

You could have one session for a gallery, another for authenticated image requests, and another for a part of the app with different cache limits.

Do not create the session inside body

One detail is easy to miss: the session should be stable.

This is not a good idea:

struct BadGalleryView: View {
    var body: some View {
        GalleryContent()
            .asyncImageURLSession(URLSession(configuration: .default))
    }
}

SwiftUI can evaluate body many times. You do not want to create networking infrastructure as a side effect of view evaluation.

Use a stable dependency instead. A static property is enough for a simple example. In a real app, you might inject an image session from a dependency container, environment, or feature store.

struct GoodGalleryView: View {
    var body: some View {
        GalleryContent()
            .asyncImageURLSession(ImageStore.imageSession)
    }
}

This keeps the cache behavior predictable.

What this means for performance

This update helps with one very common source of waste: loading the same remote image again when the app could reuse a cached response.

That can improve perceived performance. Images can appear faster after the first load. Scrolling back to already-seen content can feel less noisy. The app may avoid some network work.

But I would be careful with the conclusion. Caching does not remove all image cost. Even if the response comes from cache, the app may still need to decode the image. If the source image is much larger than the view that displays it, the app may still pay unnecessary memory and CPU cost. If many images appear at once, layout, decoding, rendering, and memory pressure can still become visible.

So yes, this is a good improvement. But no, it does not make image-heavy screens automatically cheap.

Server headers matter now even more

Because AsyncImage now respects standard HTTP caching, backend and CDN behavior becomes more visible in the app.

For static images, you usually want strong caching:

Cache-Control: public, max-age=31536000, immutable

That works well when the URL changes every time the content changes.

For images that can change at the same URL, you need a more careful strategy. Maybe the server uses ETag or Last-Modified. Maybe the client uses a version parameter. Maybe the product accepts a short stale period.

Image caching is not only an iOS problem. If the server sends poor cache headers, the client code becomes more complicated. If the server has a clean caching model, AsyncImage can stay simple.

When AsyncImage is enough

I would now be more comfortable using AsyncImage for simple and medium-complexity screens.

It is a good fit when:

  • The number of images is moderate.
  • The images are not huge.
  • The server sends correct cache headers.
  • You do not need prefetching.
  • You do not need custom decoding or resizing.
  • You do not need detailed retry or priority rules.

For example, AsyncImage is probably fine for profile headers, simple cards, icons loaded from a backend, article thumbnails, or a small grid where images come from a properly configured CDN.

The API is still small, but now it has a better default.

When I would still use a dedicated image pipeline

I would still reach for a dedicated image pipeline in a serious feed, large catalog, media-heavy chat, or photo gallery.

Those screens usually need more than HTTP caching. They need memory cache for decoded images. They need downsampling. They need request deduplication. They may need preheating before cells appear. They may need cancellation and priority control based on scroll speed.

That is where libraries like Nuke, Kingfisher, or SDWebImage still make sense. The new AsyncImage does not remove that category. It just makes the built-in option less limited.

A practical rule of thumb

My rule would be this: Start with AsyncImage when the screen is simple and the backend has sane cache headers.

Move to a dedicated image pipeline when image loading becomes part of the screen’s performance profile.

That sounds obvious, but it is a useful boundary. Do not bring a full image framework into a screen that shows three small images. Also do not force AsyncImage into a large feed just because it is built into SwiftUI.

Use the tool that matches the cost of the screen.

Final thoughts

I like this update because it makes AsyncImage more honest as a production API. The first version was convenient, but once you cared about caching, the answer often became “build something else.” Now there is a better middle ground.

You can keep the SwiftUI phase-based API. You can rely on standard HTTP caching by default. If you need more control, you can pass a URLRequest. If you need a custom cache setup, you can provide a URLSession for a whole part of the view tree.

That is a good direction. Just do not confuse HTTP caching with a full image loading architecture. For many screens, the new AsyncImage will be enough. For image-heavy screens, it is still only one piece of the pipeline.