AsyncImage Is Not Enough: Build A Cached Image Loader In SwiftUI
AsyncImage is one of those SwiftUI APIs that feels like it should end the whole remote image conversation:
AsyncImage(url: avatarURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
ProgressView()
}
For a settings screen avatar, a small marketing image, or a prototype, that might be plenty. Apple describes AsyncImage as a view that asynchronously loads and displays an image from a URL. It uses the shared URLSession, shows a placeholder while loading, and can hand you an AsyncImagePhase so you can switch between empty, success, and failure UI.
Update for WWDC26: AsyncImage gained HTTP caching behavior, URLRequest initializers, and view-scoped custom URLSession support on iOS 27 and the other OS 27 Apple platforms. I wrote a deeper follow-up here: WWDC26 AsyncImage Caching: What Changed And How To Use It.
What it does not give you is the part most image-heavy apps eventually need: a cache of decoded images you control, explicit retry behavior, a cancellation point you own, request customization, downsampling before display, prefetching, or a policy for how much memory your image layer is allowed to use.
That does not mean AsyncImage never benefits from caching. Because it uses URLSession, normal HTTP caching can still be involved through URLCache when the request, response headers, and session configuration allow it. The important distinction is that URLCache stores URL responses. It is not the same thing as keeping a decoded, downsampled UIImage ready for your list cell. For that, NSCache is a better fit.
Let’s build the small version I would be comfortable using in a production app before reaching for a dependency.
Start with a memory cache
NSCache is meant for temporary objects that are expensive to recreate. It is thread-safe, and the system can evict objects when memory gets tight. That is exactly the mood we want for decoded images.
import UIKit
struct ImageCacheKey: Hashable {
let url: URL
let pixelWidth: Int
let pixelHeight: Int
init(url: URL, pointSize: CGSize, scale: CGFloat) {
self.url = url
self.pixelWidth = Int((pointSize.width * scale).rounded(.up))
self.pixelHeight = Int((pointSize.height * scale).rounded(.up))
}
var storageKey: NSString {
"\(url.absoluteString)|\(pixelWidth)x\(pixelHeight)" as NSString
}
}
final class ImageCache: @unchecked Sendable {
static let shared = ImageCache()
private let storage = NSCache<NSString, UIImage>()
init(countLimit: Int = 300, totalCostLimit: Int = 80 * 1024 * 1024) {
storage.countLimit = countLimit
storage.totalCostLimit = totalCostLimit
}
subscript(key: ImageCacheKey) -> UIImage? {
get {
storage.object(forKey: key.storageKey)
}
set {
if let image = newValue {
storage.setObject(image, forKey: key.storageKey, cost: image.cacheCost)
} else {
storage.removeObject(forKey: key.storageKey)
}
}
}
}
private extension UIImage {
var cacheCost: Int {
guard let cgImage else { return 1 }
return cgImage.bytesPerRow * cgImage.height
}
}
This cache uses the URL plus target pixel size as the key. The size part matters if the same original image can appear as a tiny avatar and a full-width header; otherwise you might reuse a thumbnail where you wanted a larger image. The @unchecked Sendable conformance is there because the cache is shared across tasks and the wrapper relies on NSCache’s documented thread-safe behavior.
Model the loading phases
AsyncImagePhase is specific to AsyncImage, so we will make our own small phase enum:
enum CachedImagePhase {
case empty
case loading
case success(UIImage)
case failure(Error)
}
The view should not need to know whether the image came from memory, the network, or a retry. It only needs to render the current phase.
Build the loader
This loader checks NSCache first, downloads with URLSession, validates HTTP status codes, downscales the image data to the size the UI actually needs, and publishes one of our phases.
import ImageIO
import SwiftUI
import UIKit
enum ImageLoaderError: LocalizedError {
case badStatusCode(Int)
case invalidImageData
var errorDescription: String? {
switch self {
case .badStatusCode(let code):
return "The server returned status code \(code)."
case .invalidImageData:
return "The image data could not be decoded."
}
}
}
@MainActor
final class ImageLoader: ObservableObject {
@Published private(set) var phase: CachedImagePhase = .empty
private let cache: ImageCache
private let session: URLSession
private var task: Task<Void, Never>?
private var loadID = UUID()
init(cache: ImageCache = .shared, session: URLSession = .shared) {
self.cache = cache
self.session = session
}
func load(_ url: URL, targetSize: CGSize, scale: CGFloat) {
let cacheKey = ImageCacheKey(url: url, pointSize: targetSize, scale: scale)
if let cachedImage = cache[cacheKey] {
cancel()
phase = .success(cachedImage)
return
}
cancel()
phase = .loading
let id = UUID()
loadID = id
task = Task {
defer {
if loadID == id {
task = nil
}
}
do {
let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy)
let (data, response) = try await session.data(for: request)
try Task.checkCancellation()
if let statusCode = (response as? HTTPURLResponse)?.statusCode,
!(200..<300).contains(statusCode) {
throw ImageLoaderError.badStatusCode(statusCode)
}
let image = try await Task.detached(priority: .utility) {
try Self.downsample(data: data, to: targetSize, scale: scale)
}.value
try Task.checkCancellation()
if loadID == id {
cache[cacheKey] = image
phase = .success(image)
}
} catch is CancellationError {
// The view disappeared or a newer request replaced this one.
} catch {
if loadID == id {
phase = .failure(error)
}
}
}
}
func cancel() {
loadID = UUID()
task?.cancel()
task = nil
}
nonisolated private static func downsample(
data: Data,
to pointSize: CGSize,
scale: CGFloat
) throws -> UIImage {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else {
throw ImageLoaderError.invalidImageData
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: Int(maxDimensionInPixels)
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
source,
0,
downsampleOptions as CFDictionary
) else {
throw ImageLoaderError.invalidImageData
}
return UIImage(cgImage: cgImage)
}
}
The loadID looks a little fussy, but it solves a real list-cell problem. If request A is cancelled and request B starts right after it, request A should not be able to publish a stale failure or clear the current task when it finally unwinds.
Downsampling matters too. If the server sends a 3000 by 3000 image and your row displays it at 72 points, decoding the full image wastes memory before SwiftUI ever clips it. CGImageSourceCreateThumbnailAtIndex lets us decode closer to the size we will actually show.
Wrap it in a SwiftUI view
Now we can expose a small view that feels similar to AsyncImage, but with explicit placeholder, failure, cancellation, retry, and target size behavior.
struct CachedAsyncImage<Content: View, Placeholder: View, Failure: View>: View {
let url: URL
let targetSize: CGSize
@Environment(\.displayScale) private var displayScale
@StateObject private var loader: ImageLoader
private let content: (Image) -> Content
private let placeholder: () -> Placeholder
private let failure: (Error, @escaping () -> Void) -> Failure
init(
url: URL,
targetSize: CGSize,
cache: ImageCache = .shared,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder,
@ViewBuilder failure: @escaping (Error, @escaping () -> Void) -> Failure
) {
self.url = url
self.targetSize = targetSize
_loader = StateObject(wrappedValue: ImageLoader(cache: cache))
self.content = content
self.placeholder = placeholder
self.failure = failure
}
var body: some View {
Group {
switch loader.phase {
case .empty, .loading:
placeholder()
case .success(let uiImage):
content(Image(uiImage: uiImage))
case .failure(let error):
failure(error) {
loader.load(url, targetSize: targetSize, scale: displayScale)
}
}
}
.task(id: ImageCacheKey(url: url, pointSize: targetSize, scale: displayScale)) {
loader.load(url, targetSize: targetSize, scale: displayScale)
}
.onDisappear {
loader.cancel()
}
}
}
And here it is in a row:
struct ArticleRow: View {
let article: Article
var body: some View {
HStack(spacing: 12) {
CachedAsyncImage(
url: article.thumbnailURL,
targetSize: CGSize(width: 88, height: 88)
) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 10)
.fill(.secondary.opacity(0.15))
.overlay {
ProgressView()
}
} failure: { _, retry in
Button(action: retry) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.frame(width: 88, height: 88)
.background(.secondary.opacity(0.15))
}
.frame(width: 88, height: 88)
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 4) {
Text(article.title)
.font(.headline)
Text(article.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
The .task(id:) modifier starts loading when the view appears and restarts if the URL or target pixel size changes. onDisappear cancels work for rows that scroll offscreen. The retry button asks the loader to try again, so it still gets the cache check and phase handling.
URLCache vs NSCache
URLCache belongs to the networking layer. It maps URL requests to cached URL responses, can use memory and disk, and is governed by request cache policy plus HTTP caching headers. The default URLSession configuration uses the shared URL cache, but whether a specific image response is reusable depends on the server and request.
NSCache belongs to your object layer. In this post we use it for decoded, downsampled UIImage instances. It is memory-only, automatically evictable, and great for avoiding repeated decode work while the user scrolls around. It is not persistent storage, and you should expect objects to disappear.
Use both layers when it helps. URLCache can avoid downloading bytes again. NSCache can avoid decoding and resizing those bytes again.
When to use Nuke or Kingfisher
This custom loader is intentionally small. It is nice when you want a dependency-free image path and behavior you can read in one file.
Reach for Nuke, Kingfisher, or another mature image library when images are a major part of the product. Libraries can give you request coalescing, disk image caches, prefetching, priority management, progressive loading, animated image support, processors, metrics, and years of edge cases you do not have to rediscover one bug report at a time.
Further reading:
- Apple documentation: https://developer.apple.com/documentation/swiftui/asyncimage
- Apple documentation: https://developer.apple.com/documentation/foundation/urlcache
- Apple documentation: https://developer.apple.com/documentation/foundation/nscache
Practical wrap-up
AsyncImage is a good default for simple remote images. Once you care about memory, cancellation, retry UI, and list behavior, a small loader like this gives you control without much code.
Keep URLCache and NSCache in their own lanes, downsample before display, cancel work when views disappear, and move to a library when your image pipeline starts becoming a product feature instead of a helper.