```html SwiftUI: How to Build Image Caching (iOS 17+, 2026)

How to Build Image Caching in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: AsyncImage / NSCache Updated: May 11, 2026
TL;DR

Drop-in replace AsyncImage with a custom CachedAsyncImage view backed by a shared NSCache<NSString, UIImage> singleton. The cache intercepts every URL load — if the image is already in memory, it renders instantly without hitting the network.

// Minimal usage
CachedAsyncImage(url: URL(string: "https://example.com/photo.jpg")) { image in
    image
        .resizable()
        .scaledToFill()
} placeholder: {
    ProgressView()
}
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 12))

Full implementation

The strategy is a two-layer lookup: first check the in-memory NSCache, then fall back to a URLSession network fetch and store the result. The cache is a thread-safe singleton whose memory pressure is managed automatically by the OS — no manual eviction needed. The CachedAsyncImage view is fully generic so you can style the loaded image and placeholder independently, just like the built-in AsyncImage.

import SwiftUI

// MARK: - Cache

final class ImageCache: @unchecked Sendable {
    static let shared = ImageCache()

    private let cache: NSCache<NSString, UIImage> = {
        let c = NSCache<NSString, UIImage>()
        c.countLimit = 150                          // max objects
        c.totalCostLimit = 75 * 1024 * 1024        // 75 MB
        return c
    }()

    private init() {}

    func image(for url: URL) -> UIImage? {
        cache.object(forKey: url.absoluteString as NSString)
    }

    func store(_ image: UIImage, for url: URL) {
        // cost = bytes, lets NSCache evict large images first
        let cost = Int(image.size.width * image.size.height * image.scale * 4)
        cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
    }
}

// MARK: - CachedAsyncImage

struct CachedAsyncImage<Content: View, Placeholder: View>: View {
    let url: URL?
    @ViewBuilder var content: (Image) -> Content
    @ViewBuilder var placeholder: () -> Placeholder

    @State private var uiImage: UIImage?
    @State private var loadError: Error?

    var body: some View {
        Group {
            if let uiImage {
                content(Image(uiImage: uiImage))
            } else if loadError != nil {
                // Caller can handle via placeholder or swap content view
                placeholder()
            } else {
                placeholder()
                    .task(id: url) { await load() }
            }
        }
    }

    // MARK: Private

    @MainActor
    private func load() async {
        guard let url else { return }

        // 1. Cache hit — render immediately
        if let cached = ImageCache.shared.image(for: url) {
            uiImage = cached
            return
        }

        // 2. Network fetch
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
            guard let image = UIImage(data: data) else { return }
            ImageCache.shared.store(image, for: url)
            uiImage = image
        } catch {
            loadError = error
        }
    }
}

// MARK: - Demo

struct ImageCacheDemoView: View {
    private let urls: [URL] = (1...12).compactMap {
        URL(string: "https://picsum.photos/seed/\($0)/300/300")
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
                    ForEach(urls, id: \.absoluteString) { url in
                        CachedAsyncImage(url: url) { image in
                            image
                                .resizable()
                                .scaledToFill()
                        } placeholder: {
                            ZStack {
                                Color(.systemGray5)
                                ProgressView()
                            }
                        }
                        .frame(width: 100, height: 100)
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                        .accessibilityLabel("Cached photo")
                    }
                }
                .padding()
            }
            .navigationTitle("Image Cache Demo")
        }
    }
}

#Preview {
    ImageCacheDemoView()
}

How it works

  1. NSCache singletonImageCache.shared holds an NSCache<NSString, UIImage> instance with a 75 MB cost cap. NSCache automatically evicts entries when the system is under memory pressure, so you never need to call removeAllObjects() yourself.
  2. Cost-aware storagestore(_:for:) computes the pixel byte cost (width × height × scale × 4) so the cache evicts large images before small ones, maximising the number of distinct images kept in memory.
  3. .task(id: url) modifier — attaching the task to the URL means SwiftUI automatically cancels the in-flight request and restarts if the URL changes (e.g., inside a recycled list cell), preventing stale image bleed.
  4. HTTP status guard — the statusCode == 200 check in load() prevents caching of error responses (404 HTML pages delivered as data) as broken images.
  5. Generic content & placeholder closures — identical ergonomics to Apple's AsyncImage initialiser, so swapping between the two is a one-word rename across your project.

Variants

Disk cache fallback (NSCache + FileManager)

Persist downloaded images to the Caches directory so they survive app restarts. Check memory first, then disk, then network.

extension ImageCache {
    private var cacheDir: URL {
        FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("ImageCache", isDirectory: true)
    }

    func diskImage(for url: URL) -> UIImage? {
        let file = cacheDir.appendingPathComponent(url.lastPathComponent)
        guard let data = try? Data(contentsOf: file) else { return nil }
        return UIImage(data: data)
    }

    func storeToDisk(_ image: UIImage, for url: URL) {
        try? FileManager.default.createDirectory(
            at: cacheDir, withIntermediateDirectories: true)
        let file = cacheDir.appendingPathComponent(url.lastPathComponent)
        try? image.jpegData(compressionQuality: 0.85)?.write(to: file)
    }
}

// In load(), between cache hit and network fetch:
// if let disk = ImageCache.shared.diskImage(for: url) {
//     ImageCache.shared.store(disk, for: url) // warm memory cache
//     uiImage = disk; return
// }

Cache-busting with ETag / Last-Modified

For images that change at the same URL (avatars, product shots), store the ETag or Last-Modified response header alongside the cached image. On the next fetch, send an If-None-Match / If-Modified-Since request header; a 304 Not Modified response means your cached copy is still valid. This eliminates redundant data transfers while keeping images fresh without hard-coding TTLs.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement image caching in SwiftUI for iOS 17+.
Use AsyncImage/NSCache.
Make it accessible (VoiceOver labels).
Add a #Preview with realistic sample data.

Paste this into Soarias during the Build phase after your screens are scaffolded — it drops a production-ready ImageCache + CachedAsyncImage pair directly into your feature module without touching unrelated files.

Related

FAQ

Does this work on iOS 16?

The NSCache layer and URLSession fetch work on iOS 15+. However, .task(id:) requires iOS 15 and the @unchecked Sendable annotation on the cache class is a Swift 5.7+ concept that Apple recommends pairing with strict concurrency checking introduced in Xcode 15 / iOS 17 targets. You can deploy to iOS 16 by removing @unchecked Sendable and ensuring all cache access happens on the main actor, but you'll lose the automated cancellation-on-reuse behaviour for free cells. iOS 17+ is strongly recommended for correctness.

Why not just use URLCache instead of NSCache?

URLCache respects HTTP cache-control headers and persists to disk automatically, which sounds ideal. In practice, many image CDNs send Cache-Control: no-store or very short max-age values, so URLCache silently re-fetches on every view. An explicit NSCache layer gives you full control: you decide when images are fresh, regardless of server headers. Use both together for belt-and-suspenders: let URLCache handle HTTP semantics, and front it with NSCache for synchronous in-memory hits.

What's the UIKit equivalent?

In UIKit you'd typically use UIImageView with a custom extension that calls URLSession.shared.dataTask(with:), stores results in an NSCache<NSString, UIImage> singleton, and sets imageView.image back on DispatchQueue.main. The SwiftUI approach here mirrors that pattern but replaces completion handlers with async/await and manages view lifecycle via .task(id:). Libraries like Kingfisher and SDWebImage provide polished UIKit and SwiftUI equivalents if you need features like animated GIF support or progressive JPEG loading.

Last reviewed: 2026-05-11 by the Soarias team.

```