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:

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.