SwiftUI’s AsyncImage
is handy, but every time your view appears, it refetches the image—leading to flicker, delays, and unnecessary network use. What if you could fetch once, then reuse instantly? That's exactly what the Cached Async Image delivers: a memory-powered caching layer that keeps SwiftUI image loading smooth, snappy, and resilient.
First a simple in-memory cache without disk persistence. This will be thread-safe and auto-purges under memory pressure. A Singleton wrapping NSCache for URL → UIImage caching as follows :
final class ImageCache {
static let shared = ImageCache()
private init() {}
private let cache = NSCache<NSURL, UIImage>()
func image(for url: URL) -> UIImage? {
cache.object(forKey: url as NSURL)
}
func insertImage(_ image: UIImage?, for url: URL) {
guard let image else { return }
cache.setObject(image, forKey: url as NSURL)
}
func clearAll() {
cache.removeAllObjects()
}
}
Now, AsyncImageLoader written as ObservableObject which will first check whether image present in the cache. If Yes, then returns immediately. Otherwise it fetches fresh image via Combine (
dataTaskPublisher
) request.// Check cache first
if let cached = ImageCache.shared.image(for: url) {
self.image = cached
...
}
In CachedAsyncImage, below method gives interface for our UI needs.
// Otherwise Fetch fresh image and save to cache
cancellable = URLSession.shared.dataTaskPublisher(for: url)
.timeout(.seconds(timeout), scheduler: DispatchQueue.main, customError: { URLError(.timedOut) })
.tryMap { output -> UIImage in
// Converting to UIImage object from blob response
guard let image = UIImage(data: output.data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: {
// Handle completion state
// Handle completion state
}, receiveValue: { [weak self] downloaded in
guard let self else { return }
// Save the image to cache
ImageCache.shared.insertImage(downloaded, for: url)
self.image = downloaded
completion?(.success(downloaded))
})
In CachedAsyncImage, below method gives interface for our UI needs.
loader.load(from: url) { result in
switch result {
case .success(let image):
self.loader.image = image
case .failure(let err):
self.loader.error = err
}
}
So that we can simply drop-in for SwiftUI usage, where no unnecessary refetches for cached images.
CachedAsyncImage(url: <URL goes here>) {
ProgressView()
}
.scaledToFit()
.frame(width: 200, height: 200)

Checkout complete source code of above example from github link.
Next Steps & Enhancements
- Disk cache: persist images across launches—store in Caches directory.
- Resize before caching: reduce memory footprint for large images.
- Error UI: Flexible to use any default placeholder instead of Color.clear.
- Cache policies: limit entries or manage multiple sizes (thumbnail/full).
- Authenticated fetching: use custom URLRequest if you need headers or auth.