Beyond AsyncImage: A Cancellable, Cache-Aware SwiftUI Image Pipeline
AsyncImage is an excellent default for a prototype or a simple remote image. A production feed often needs a more explicit policy: validated HTTP responses, cancellation when cells disappear, bounded caching, retry behavior, and decoding appropriate for the displayed size.
Putting all of that in a custom View creates a component that owns networking, storage, image conversion, and UI state. A clearer pipeline has three boundaries. A loader retrieves and caches bytes. A main-actor model converts the result into presentation state. SwiftUI renders that state and owns the task lifetime.
Cache bytes behind an actor
Caching Data avoids moving UIKit image objects across an actor boundary. This minimal actor demonstrates in-process reuse. It is not yet a complete cache because it has no capacity or eviction policy.
import Foundation
actor ImageDataLoader {
private var memory: [URL: Data] = [:]
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func data(for url: URL) async throws -> Data {
if let cached = memory[url] {
return cached
}
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode),
http.mimeType?.hasPrefix("image/") == true else {
throw URLError(.badServerResponse)
}
try Task.checkCancellation()
memory[url] = data
return data
}
}
Checking the status and MIME type prevents an HTML error page from entering the successful image cache. The cancellation check happens before mutation, so a task that is no longer needed does not change cache state after its caller has gone away.
This example does not coalesce two simultaneous requests for the same URL. A fuller implementation can store an in-flight Task<Data, Error> per URL and let callers await it. Remove that task on both success and failure, and decide deliberately whether cancellation by one waiter should cancel shared work for every waiter.
Keep presentation state on the main actor
The UI model describes states the view can render:
import Observation
import SwiftUI
@Observable
@MainActor
final class RemoteImageModel {
enum State {
case idle, loading, loaded(Image), failed
}
private(set) var state: State = .idle
func load(_ url: URL, using loader: ImageDataLoader) async {
state = .loading
do {
let data = try await loader.data(for: url)
try Task.checkCancellation()
guard let image = UIImage(data: data) else {
state = .failed
return
}
state = .loaded(Image(uiImage: image))
} catch is CancellationError {
state = .idle
} catch {
state = .failed
}
}
}
Decoding occurs where the platform image becomes presentation state. The model distinguishes idle, loading, success, and failure instead of using several booleans that could represent impossible combinations.
A view can call the method from .task(id: url). SwiftUI cancels the old task when the identity changes or the view leaves the hierarchy. Cancellation remains cooperative: the session, loader, and model must propagate or check it instead of catching every error and turning cancellation into a red failure icon.
Treat HTTP caching as the first cache
An unbounded dictionary is not a production cache. Prefer a correctly configured URLCache and respect server Cache-Control, ETag, and Last-Modified semantics. That gives the application revalidation and disk behavior without inventing a second HTTP protocol.
Add a custom memory cache when the product needs transformed-image reuse or when profiling shows decoding is a bottleneck. Give it a cost limit, an eviction policy, and a response to memory pressure. The cache key may need more than the URL if requested pixel size or transformation affects the result.
Decode close to the display size. Downloading and fully decoding a huge photograph for a small list thumbnail wastes bandwidth and peak memory. A server-provided thumbnail is usually best. Otherwise, use Image I/O downsampling and include the target pixel dimensions and display scale in the transformation decision.
Prefetching should share the same repository as visible loading instead of creating a second cache. A prefetch task may use lower priority, while a newly visible caller awaits the same in-flight request. Define cancellation for multiple waiters: one cell leaving the screen should not necessarily cancel work still needed by another visible cell. The repository can own the underlying task while cancellation stops one caller’s wait; cancelling the shared task when no waiters remain requires explicit accounting.
Authenticated images need a privacy policy as well as an eviction policy. Avatars, receipts, or resources with signed URLs may not belong in a shared disk cache. Review file protection, sign-out cleanup, and sensitive query parameters. Prefer a normalized resource identifier for persistent cache keys instead of embedding a short-lived credential in a filename, and avoid printing complete protected URLs in logs.
Test the boundaries
Inject a session configured with a test URL protocol or use a small loader protocol around this actor. Cover a valid image, a 404 response, an incorrect MIME type, malformed image bytes, a slow response that is cancelled, and a retry after failure. A cache test should prove that the second successful read does not issue another request and that failed responses do not enter the success cache.
A dependable image pipeline is not primarily a collection of view modifiers. It is a set of explicit responsibilities: HTTP validates the response, caching manages lifetime and cost, the main actor owns presentation state, and SwiftUI owns the visible task. Once those boundaries are clear, prefetching, retries, and richer placeholders can be added without turning image loading into hidden global behavior.