How to Build Image Caching in SwiftUI
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
-
NSCache singleton —
ImageCache.sharedholds anNSCache<NSString, UIImage>instance with a 75 MB cost cap.NSCacheautomatically evicts entries when the system is under memory pressure, so you never need to callremoveAllObjects()yourself. -
Cost-aware storage —
store(_: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. -
.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. -
HTTP status guard — the
statusCode == 200check inload()prevents caching of error responses (404 HTML pages delivered as data) as broken images. -
Generic content & placeholder closures — identical ergonomics to Apple's
AsyncImageinitialiser, 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
-
⚠️
iOS 16 and earlier: The
.task(id:)modifier is only available from iOS 15+, but the@unchecked Sendableconformance onNSCachewrappers requires careful auditing below iOS 17. Stick to iOS 17+ as the deployment target to use Swift Concurrency safely. -
⚠️
URL key collisions: If two different CDN domains serve images with identical
lastPathComponentfilenames (e.g.,photo.jpg), the disk cache variant will collide. Always key on the fullabsoluteStringin NSCache and use a hashed filename (e.g., SHA256 of the URL) on disk. -
⚠️
Main-thread image decode: Constructing a
UIImagefrom data and immediately assigning it to@Statedecodes on the main actor, which can stutter on large images. For images above ~1 MP, callUIImage(data:).preparingForDisplay()off the main actor before the@MainActorassignment. -
⚠️
Accessibility: The placeholder
ProgressViewannounces "In progress" indefinitely to VoiceOver if the load fails silently. Always set.accessibilityLabelon the loaded image and.accessibilityHidden(true)on loading spinners.
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.