WWDC26 AsyncImage Caching: What Changed And How To Use It
AsyncImage has always been the SwiftUI API that looked like it should solve remote images:
AsyncImage(url: product.thumbnailURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
ProgressView()
}
For a prototype, a settings avatar, or a screen with one remote illustration, that was fine. For a feed, product grid, chat roster, or media browser, it usually was not. You could scroll a cell off screen, scroll back, and watch the same image load again. You also had no direct way to pass a URLRequest or scope a custom URLSession to the AsyncImage views inside one part of the app.
That is why the older article on this site, AsyncImage Is Not Enough: Build A Cached Image Loader In SwiftUI, built a custom loader with NSCache, explicit cancellation, downsampling, and retry behavior.
WWDC26 changes the calculation.
In iOS 27, iPadOS 27, macOS 27, Mac Catalyst 27, watchOS 27, tvOS 27, visionOS 27, and the matching SDKs, AsyncImage picked up three practical improvements:
- downloaded image data can be cached according to the transport protocol, which mainly means normal HTTP caching rules
AsyncImagecan now be initialized with aURLRequest, not just aURL- a view hierarchy can provide the
URLSessionthat containedAsyncImageinstances use for image data tasks
This does not make every custom image pipeline obsolete. It does mean a lot of everyday app images no longer need a homegrown loader just to avoid wasteful reloads.
One beta note before the code: the snippets below follow Apple’s WWDC26 / Xcode 27 beta API shape as documented on June 27, 2026. Let your installed SDK and Xcode autocomplete be the source of truth for exact availability and initializer spelling before shipping.
Quick decision guide
- Public, versioned, already-sized thumbnails: use normal
AsyncImage(url:)and make sure the CDN sends strong cache headers. - Same URL, stale image is acceptable for a while: use a
URLRequestwith.returnCacheDataElseLoad, knowing it can return expired cached data. - User just changed an image: prefer a new versioned URL. If you cannot do that yet, use a request that ignores the local cache only on the immediate refresh path.
- One feature needs different network behavior: scope a custom
URLSessionwithasyncImageURLSession(_:). - Private or regulated images: combine authenticated requests, a feature-specific session cache policy, and server-side
Cache-Controlthat matches the privacy requirement. - Huge images, transforms, prefetching, or old-OS parity: keep a custom image pipeline.
What actually changed
The important phrase is not “AsyncImage caches everything forever.” It is closer to this:
AsyncImage now caches downloaded image data following the transport protocol.
That distinction matters. HTTP caching is a contract between the client, the URL request, and the server response. If the server says an image can be reused for an hour, AsyncImage can reuse it. If the server says no-store, the system should not keep it. If the response has an ETag or Last-Modified, the next load can revalidate instead of blindly downloading the whole image again.
In other words, you get the most benefit when your image server or CDN already sends useful cache headers.
The other important detail is where the cache lives. Apple says the system creates the cache with a default URLSessionConfiguration. That means this is still URL loading behavior: cached response data through URLCache, not a decoded-image cache owned by SwiftUI.
For a versioned asset URL, this is ideal:
Cache-Control: public, max-age=31536000
Some CDNs also add directives such as immutable, but the portable point here is the long max-age on a URL that changes when the content changes.
For an avatar that might change soon, you probably want a shorter lifetime:
Cache-Control: private, max-age=300
ETag: "avatar-v42"
For sensitive one-time content, you might intentionally opt out:
Cache-Control: no-store
That means the app-side API is only half of the story. The new AsyncImage behavior helps most when your backend treats image URLs as cacheable resources instead of anonymous bytes.
The zero-code win
The first win is that a lot of existing code gets better without changing the view.
struct ProductThumbnail: View {
let imageURL: URL?
var body: some View {
AsyncImage(
url: imageURL,
transaction: Transaction(animation: .easeInOut(duration: 0.18))
) { phase in
switch phase {
case .empty:
thumbnailPlaceholder
case .success(let image):
image
.resizable()
.scaledToFill()
.transition(.opacity)
case .failure:
thumbnailFailure
@unknown default:
thumbnailPlaceholder
}
}
.frame(width: 88, height: 88)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.accessibilityHidden(true)
}
private var thumbnailPlaceholder: some View {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.secondary.opacity(0.15))
.overlay {
ProgressView()
}
}
private var thumbnailFailure: some View {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.secondary.opacity(0.12))
.overlay {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
}
}
On the new OS releases, this view can benefit from HTTP caching when the response headers allow it. That is exactly the kind of improvement that matters in a product grid: the user opens a category, scrolls down, scrolls back up, and the same thumbnails can often reuse a cached response or avoid downloading the image body again.
The code above is still intentionally simple. You get loading, success, failure, and a fade. You do not get image preheating, downsampling control, a decoded image memory cache, custom transforms, or priority management. For many normal thumbnails, that is a good trade.
When the default behavior helps
Think about image lifetimes.
A product thumbnail uploaded to a CDN is usually stable. If the image changes, the URL can change from shoe.png to shoe-a83f.png, or the CDN can update the validator. This is where the new behavior shines. You can lean on AsyncImage and HTTP caching, and spend your effort on server headers and URL versioning.
A user avatar is semi-stable. It may be shown in a profile header, comments list, message thread, mentions sheet, search results, and notifications view. Caching helps a lot, but you need a refresh strategy when the user changes the avatar. The simplest strategy is versioning:
struct UserSummary: Identifiable {
let id: UUID
let displayName: String
let avatarBaseURL: URL
let avatarVersion: Int
var avatarURL: URL {
avatarBaseURL.appending(queryItems: [
URLQueryItem(name: "v", value: String(avatarVersion))
])
}
}
Now a normal AsyncImage(url:) can cache aggressively, and an avatar update naturally produces a new URL. You do not have to fight the cache because the resource identity changed.
A news image, event poster, or restaurant menu photo sits in the middle. You may prefer a cached response while the user is browsing, but you also want revalidation after a short window. Let the server send a short max-age plus an ETag, and keep the app on the default protocol policy.
A private receipt image, medical attachment, or temporary signed URL is different. Caching is not automatically good. The right behavior may be memory-only, short lived, or no-store at the server. The new API gives you better control, but it does not make that product decision for you.
Use URLRequest when the image has a policy
The new URLRequest initializers are the part I expect to use most often.
Before WWDC26, AsyncImage gave you a URL-shaped input. If you needed a cache policy, timeout, or header, you were out of the happy path. Now the request can carry that policy directly:
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
struct RequestBackedThumbnail: View {
let request: URLRequest
@Environment(\.displayScale) private var displayScale
var body: some View {
AsyncImage(
request: request,
scale: displayScale,
transaction: Transaction(animation: .easeInOut(duration: 0.18))
) { phase in
switch phase {
case .empty:
Color.secondary.opacity(0.12)
case .success(let image):
image
.resizable()
.scaledToFill()
.transition(.opacity)
case .failure:
Image(systemName: "photo")
.font(.title2)
.foregroundStyle(.secondary)
@unknown default:
Color.secondary.opacity(0.12)
}
}
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
The scale parameter is easy to misunderstand. It is the image scale, similar to @2x or @3x asset scale. It is not a downsampling size, and it is not a layout constraint. You still size and clip the view with normal SwiftUI layout.
I usually prefer to build the request outside the view:
enum RemoteImageRequest {
static func normal(_ url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.cachePolicy = .useProtocolCachePolicy
request.timeoutInterval = 15
return request
}
static func preferCached(_ url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad
request.timeoutInterval = 8
return request
}
static func reloadIgnoringLocalCache(_ url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 15
return request
}
}
Use those policies deliberately:
.useProtocolCachePolicyis the default for most app images because it respects HTTP caching.returnCacheDataElseLoadis useful when stale imagery for the same URL is better than empty UI; it can return cached data without first checking whether that response has expired.returnCacheDataDontLoadis useful only when the feature must not touch the network and can tolerate failure when nothing is cached.reloadIgnoringLocalCacheDatais useful after the user explicitly changed an image and you need to bypass the device’s local cache for that refresh
Do not sprinkle .reloadIgnoringLocalCacheData everywhere because one screen had a stale avatar once. That throws away the main benefit, and it still does not guarantee that every proxy or CDN layer has a newer object. Fix the server headers, version the URL, or bypass the local cache only for the user action that needs it.
One more request detail from Apple’s docs is worth keeping in mind: if the request is nil, or if a new request replaces an old one before loading finishes, the phase is .empty until the operation completes. If you want a transition when the request changes, give the AsyncImage a stable identity tied to the image resource:
AsyncImage(
request: RemoteImageRequest.normal(photo.imageURL),
transaction: Transaction(animation: .easeInOut(duration: 0.2))
) { phase in
PhotoThumbnailContent(phase: phase)
}
.id(photo.imageURL)
That keeps the animation tied to a real resource change instead of relying on whatever parent view identity happens to be doing.
Real situation: a product catalog
Imagine a shopping app with a category grid. Product names, prices, and inventory come from JSON. Product images come from a CDN.
The image URLs are content-addressed:
https://cdn.example.com/products/4817/thumb-a83f9c2.jpg
https://cdn.example.com/products/4818/thumb-f1d902e.jpg
The CDN responds with:
Cache-Control: public, max-age=31536000
That is a great fit for default AsyncImage on iOS 27 and later:
struct ProductGridCell: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: product.thumbnailURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
ProductImageFallback()
case .empty:
ProductImageSkeleton()
@unknown default:
ProductImageSkeleton()
}
}
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(product.name)
.font(.subheadline)
.lineLimit(2)
Text(product.formattedPrice)
.font(.headline)
}
}
}
The key product decision is that price and inventory are not encoded in the cached image. The stale-tolerant part is the thumbnail. The fresh part is your product JSON. That separation makes caching much safer.
If the merchandising team replaces the product photo, publish a new content-addressed image URL. The old cached response can stay valid because the URL now points to a different resource.
Real situation: a travel guide that should feel good offline
Now imagine a travel guide. The user opened a city guide at the hotel, then lost connectivity on the subway. You would rather show the cached landmark image than a blank rectangle.
That is where a request-based policy can help, as long as showing a stale cached image for that same URL is acceptable:
struct LandmarkCard: View {
let landmark: Landmark
private var imageRequest: URLRequest {
RemoteImageRequest.preferCached(landmark.heroImageURL)
}
var body: some View {
if #available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *) {
AsyncImage(request: imageRequest) { phase in
LandmarkImageContent(phase: phase)
}
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} else {
AsyncImage(url: landmark.heroImageURL) { phase in
LandmarkImageContent(phase: phase)
}
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
This is not a full offline download feature. It only helps when a response is already cached. If the user needs guaranteed offline maps, guides, and images, build a real offline content system. But for “I just saw this image five minutes ago, please do not flash an error while connectivity is bad,” this is a meaningful quality-of-life improvement.
Real situation: avatars after profile edits
Avatar caching is where teams often reach for blunt tools.
The user changes their profile photo. The app returns to the profile screen. The old image is still visible because the server or CDN said it was cacheable. Someone adds a random query item everywhere. Now every avatar bypasses caching forever.
A better approach is to separate normal display from the edit completion path.
Normal display:
struct AvatarView: View {
let user: UserSummary
var body: some View {
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Circle()
.fill(.secondary.opacity(0.18))
}
.frame(width: 44, height: 44)
.clipShape(Circle())
}
}
After the current user uploads a new avatar, update the model with a new avatar version:
@MainActor
func saveAvatar(_ imageData: Data) async throws {
let response = try await profileService.uploadAvatar(imageData)
currentUser.avatarVersion = response.avatarVersion
currentUser.avatarBaseURL = response.avatarBaseURL
}
If you cannot version the URL yet, use a request that bypasses the local cache only for the screen that immediately follows the edit:
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
struct FreshAvatarPreview: View {
let avatarURL: URL
var body: some View {
AsyncImage(request: RemoteImageRequest.reloadIgnoringLocalCache(avatarURL)) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 96, height: 96)
.clipShape(Circle())
}
}
Then go back to the normal URL/versioned path for the rest of the app.
Scope a custom URLSession to one view hierarchy
The second big API is asyncImageURLSession(_:).
This matters when one feature has different network behavior from the rest of the app. A product grid may deserve a larger disk cache. A private document viewer may need an ephemeral session. A watchOS view may need tighter timeouts. A debug build may use a URL protocol stub.
Here is a catalog image session with an explicit cache budget:
import Foundation
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
extension URLSession {
static let catalogImages: URLSession = {
let configuration = URLSessionConfiguration.default
let cacheDirectory = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("CatalogImageResponses", isDirectory: true)
configuration.urlCache = URLCache(
memoryCapacity: 64 * 1024 * 1024,
diskCapacity: 512 * 1024 * 1024,
directory: cacheDirectory
)
configuration.requestCachePolicy = .useProtocolCachePolicy
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 60
configuration.waitsForConnectivity = true
configuration.httpMaximumConnectionsPerHost = 6
return URLSession(configuration: configuration)
}()
}
Then apply it where the image-heavy feature starts:
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
struct CatalogTab: View {
var body: some View {
NavigationStack {
ProductGrid()
.navigationTitle("Catalog")
}
.asyncImageURLSession(.catalogImages)
}
}
Every AsyncImage below that modifier uses the supplied session for image download data tasks. That is cleaner than threading a custom loader through every cell when all you needed was a different session configuration.
Private images need a different session
Now take a more sensitive example: a finance app that shows receipt attachments. The images are user-specific and authenticated. You might not want a large persistent disk cache for that feature at all.
This is not just paranoia. Apple’s Foundation caching guide notes that the default protocol cache policy can cache HTTPS responses to disk. That is good for normal performance, but it can be the wrong default for private images.
Use a request for the authorization header:
struct ReceiptImageRequestFactory {
static func request(url: URL, accessToken: String) -> URLRequest {
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.cachePolicy = .useProtocolCachePolicy
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
return request
}
}
And scope the view hierarchy to a conservative ephemeral session. In the example below, disabling urlCache is the no-cache choice. .reloadIgnoringLocalCacheData only tells the request not to consult local cached data; it is not a privacy policy by itself.
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
extension URLSession {
static let privateAsyncImages: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 20
return URLSession(configuration: configuration)
}()
}
Use that session only around the private viewer:
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
struct ReceiptAttachmentViewer: View {
let receipt: Receipt
let accessToken: String
private var imageRequest: URLRequest {
ReceiptImageRequestFactory.request(
url: receipt.imageURL,
accessToken: accessToken
)
}
var body: some View {
AsyncImage(request: imageRequest) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure:
ContentUnavailableView(
"Image Unavailable",
systemImage: "doc.text.image"
)
case .empty:
ProgressView()
@unknown default:
ProgressView()
}
}
.asyncImageURLSession(.privateAsyncImages)
}
}
That is the point of the new session modifier: make the image behavior local to the feature that needs it.
If memory-only reuse is acceptable for a less sensitive feature, use an ephemeral session with a small URLCache and diskCapacity: 0 instead. Also coordinate with your backend. If an authenticated image should not be stored, the server should say so with cache headers such as Cache-Control: no-store. Do not rely only on the client doing the polite thing.
If you need finer control in a non-ephemeral session, the URL loading system also has a willCacheResponse delegate hook that lets a URLSessionDataDelegate accept the proposed cached response, reject it with nil, or replace it with a response that uses a different storage policy such as memory-only. That is a lower-level tool than most AsyncImage screens need, but it is the escape hatch when “use this session” is still too broad.
Backward compatibility without two image systems
If your app still supports iOS 26 or earlier, you cannot call the new request initializer on those OS versions. A small wrapper can keep most call sites clean:
import SwiftUI
struct CompatibleAsyncImage<Content: View, Placeholder: View>: View {
private let request: URLRequest?
private let scale: CGFloat
private let content: (Image) -> Content
private let placeholder: () -> Placeholder
init(
url: URL?,
scale: CGFloat = 1,
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
var request = url.map { URLRequest(url: $0) }
request?.cachePolicy = cachePolicy
self.request = request
self.scale = scale
self.content = content
self.placeholder = placeholder
}
var body: some View {
if #available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *) {
AsyncImage(
request: request,
scale: scale,
content: content,
placeholder: placeholder
)
} else {
AsyncImage(
url: request?.url,
scale: scale,
content: content,
placeholder: placeholder
)
}
}
}
Then a simple remote image view can opt into request policies on new OS versions while still rendering on old ones:
struct ArticleHeroImage: View {
let imageURL: URL?
var body: some View {
CompatibleAsyncImage(
url: imageURL,
cachePolicy: .returnCacheDataElseLoad
) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Rectangle()
.fill(.secondary.opacity(0.14))
}
.frame(height: 240)
.clipped()
}
}
Use .returnCacheDataElseLoad here only if an expired cached hero image is better than an empty state. This wrapper does not magically add WWDC26 caching behavior to old OS versions. It just gives you one call site. On older systems, you are back to the old AsyncImage(url:) behavior.
If older OS image caching is important, keep your custom loader or use a proven library.
Migrating an older cached image wrapper
If you already built a CachedAsyncImage wrapper before WWDC26, do not delete it blindly. Split its responsibilities into networking, decoding, and product behavior.
If the wrapper only exists to avoid repeated network downloads, try replacing it with native AsyncImage on iOS 27 and later. The condition is that the server sends good cache headers and the image data is already close to the size you display.
If the wrapper exists because you needed a custom cache policy, move that responsibility into URLRequest:
let request = RemoteImageRequest.preferCached(article.thumbnailURL)
AsyncImage(request: request) { phase in
ArticleThumbnailContent(phase: phase)
}
If the wrapper exists because one feature needs a different cache budget, move that responsibility into a scoped URLSession:
ArticleList()
.asyncImageURLSession(.articleImages)
If the wrapper exists because you downsample large user uploads, store decoded images in NSCache, prefetch upcoming rows, or apply image processors, keep that wrapper. The new AsyncImage behavior can remove a networking workaround. It does not replace an image pipeline.
That gives you a useful migration rule: delete the code that was compensating for missing URLRequest and URLSession hooks, but keep the code that manages pixels, memory, and product-specific loading behavior.
When AsyncImage is still not enough
The new behavior is about the network response path. It does not replace every concern in an image-heavy app.
You may still want a custom loader, Nuke, Kingfisher, or another image pipeline when you need:
- decoded image memory caching keyed by URL plus display size
- downsampling before large images hit SwiftUI
- prefetching before cells appear
- priority changes as scroll velocity changes
- progressive image display
- image processors, resizing, blurring, tinting, or thumbnails derived from one original
- retry buttons with custom backoff
- request coalescing across many views
- detailed metrics, logging, and cache inspection
- full offline packs with explicit storage and eviction
The big example is downsampling.
If your server sends a 4000 by 4000 JPEG and the UI displays it in a 72 point avatar, HTTP caching can prevent a second download. It does not mean decoding that huge image is a good idea. The older custom-loader article still matters for that case because it downsampled with ImageIO before publishing a UIImage.
For a typical CDN thumbnail that is already generated at the right size, new AsyncImage plus HTTP caching may be enough. For a social feed accepting arbitrary user uploads, you probably still want an image pipeline that normalizes size, memory, and transformations.
How I would decide in a real app
Start with the server and the image type.
If the images are public, already thumbnail-sized, and have stable versioned URLs, use AsyncImage. Make sure the CDN sends strong cache headers. Add a feature-scoped URLSession only if you need a bigger cache budget or different timeout behavior.
If the images are semi-stable user content, use AsyncImage, but make URL versioning part of the model. Do not fight cache invalidation from SwiftUI if the server can give you a clearer resource identity.
If the images are private or regulated, be conservative. Use request headers, a local URLSession policy, and server-side Cache-Control that matches the privacy requirement. Avoid a shared disk cache unless you have deliberately chosen it.
If the images are huge, transformed, prefetched, or central to the product experience, keep or adopt a real image pipeline. The new API reduces the number of apps that need one. It does not remove the category.
A small cache-debugging habit
When a cached image behaves strangely, do not start in SwiftUI. Start with the response:
func printImageCacheHeaders(for url: URL) async throws {
let (_, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
return
}
let interestingHeaders = [
"Cache-Control",
"ETag",
"Last-Modified",
"Expires",
"Vary"
]
for header in interestingHeaders {
print("\(header):", httpResponse.value(forHTTPHeaderField: header) ?? "nil")
}
}
Or use curl -I against the same URL:
curl -I https://cdn.example.com/products/4817/thumb-a83f9c2.jpg
If the server says no-store, do not expect AsyncImage to save you. If the server says an unversioned avatar is fresh for a year, do not be surprised when users keep seeing the old avatar. If the response varies by authorization or locale, check the Vary header and your session choice.
This is the most useful mental model for the WWDC26 change: AsyncImage has become a better citizen of the URL loading system. That makes normal URL loading debugging more valuable, not less.
A tiny integration-test hook
For image-heavy features, I like having at least one debug or integration path that proves the view hierarchy is using the session I think it is using.
One approach is a custom URLProtocol in a test-only session:
private final class LockedRequestCounter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
defer { lock.unlock() }
value += 1
}
var count: Int {
lock.lock()
defer { lock.unlock() }
return value
}
}
final class CountingImageURLProtocol: URLProtocol {
static let requestCounter = LockedRequestCounter()
static let responseData = Data()
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "images.example.test"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
Self.requestCounter.increment()
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: [
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600"
]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
client?.urlProtocol(self, didLoad: Self.responseData)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
Then create a session for the test or debug host:
@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
func makeCountingImageSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [CountingImageURLProtocol.self]
configuration.urlCache = URLCache(
memoryCapacity: 8 * 1024 * 1024,
diskCapacity: 0,
directory: nil
)
return URLSession(configuration: configuration)
}
Replace Data() with valid image data for a real rendered test. SwiftUI image loading can be awkward to unit test directly, but this kind of hook is useful in a small integration harness because it confirms that asyncImageURLSession(_:) is actually controlling the AsyncImage requests below it.
The practical migration path
I would not rip out a working image pipeline just because AsyncImage improved.
I would do this instead:
- Find the simple remote image views that only exist because old
AsyncImagereloaded too much. - Check the image response headers for those URLs.
- If the images are already sized well and cacheable, try native
AsyncImageon iOS 27 and later. - Use
URLRequestfor the handful of screens that need a cache policy or timeout. - Use
asyncImageURLSession(_:)when a feature needs a different cache budget, privacy policy, or testing session. - Keep the custom loader where you need decoded memory caching, downsampling, prefetching, transformations, or older OS parity.
That gives you a smaller image layer without pretending every image problem is now solved by one SwiftUI view.
Source trail
This post is based on Apple’s WWDC26-era SwiftUI documentation and session material available on June 27, 2026. Because the APIs are beta in the Xcode 27 cycle, verify final names and availability against the SDK you build with.