How would you cache network responses and images in an iOS app?
TL;DR
Use two layers:
- HTTP cache (
URLCache): follows server headers likeCache-ControlandETag. Mostly free. - Image cache: an in-memory cache of decoded, downsampled images (
NSCache), plus an optional disk cache.
Also make sure two requests for the same URL share one download.
Layer 1: HTTP caching with URLCache
URLSession uses URLCache.shared by default. It stores responses in memory and on disk.
It respects the server's headers:
| Header | Meaning |
|---|---|
Cache-Control: max-age=3600 | Fresh for one hour. No network needed. |
Cache-Control: no-store | Never cache. |
ETag: "abc" | A version tag. The client sends If-None-Match: "abc" next time. |
Last-Modified | Same idea, with If-Modified-Since. |
If the content did not change, the server replies 304 Not Modified with no body. URLSession returns the cached data as a normal 200 response.
let cache = URLCache(memoryCapacity: 20 * 1024 * 1024,
diskCapacity: 200 * 1024 * 1024)
let config = URLSessionConfiguration.default
config.urlCache = cache
config.requestCachePolicy = .useProtocolCachePolicy // follow the headers
let session = URLSession(configuration: config)Other policies:
var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad // offline-first
request.cachePolicy = .reloadIgnoringLocalCacheData // always freshURLCache will not store very large responses (roughly more than 5% of the disk capacity).
Layer 2: an image cache
URLCache stores bytes. Turning bytes into a UIImage and decoding it is expensive. Cache the final image too.
actor ImageLoader {
private let cache = NSCache<NSURL, UIImage>()
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL, pointSize: CGSize, scale: CGFloat) async throws -> UIImage {
if let cached = cache.object(forKey: url as NSURL) { return cached }
if let task = inFlight[url] { return try await task.value } // share the download
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
return try downsample(data, to: pointSize, scale: scale)
}
inFlight[url] = task
defer { inFlight[url] = nil }
let image = try await task.value
cache.setObject(image, forKey: url as NSURL)
return image
}
}NSCacheis thread-safe and frees objects when memory is low.- The
inFlightdictionary stops ten cells from downloading the same avatar ten times.
Downsampling
A 4000 x 3000 photo uses about 48 MB when decoded, even if it is shown at 100 x 100 points. Use ImageIO to decode a small version directly:
import ImageIO
func downsample(_ data: Data, to size: CGSize, scale: CGFloat) throws -> UIImage {
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithData(data as CFData, options) else {
throw URLError(.cannotDecodeContentData)
}
let maxPixels = max(size.width, size.height) * scale
let thumbOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixels
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions) else {
throw URLError(.cannotDecodeContentData)
}
return UIImage(cgImage: cg)
}UIImage.byPreparingThumbnail(ofSize:) (iOS 15) is a simpler option when you already have a UIImage.
Disk cache for images
For offline use or across launches, save files in the Caches directory. The system may delete them when storage is low, so treat them as optional.
What about AsyncImage?
It is simple, but it only relies on URLCache and has no memory cache of decoded images or downsampling. For long lists, use your own loader or a library such as Nuke or Kingfisher.
Common follow-up questions
- Why
NSCacheand not aDictionary? It is thread-safe and evicts items under memory pressure. - Can
NSCacheforget an item early? Yes, any time. Always be ready to load again. - How do you cache API JSON for offline mode? Use
URLCachefor simple cases, or store parsed models in SwiftData or files for real offline support. - How do you invalidate the cache? Rely on
ETag/max-age, callremoveCachedResponse(for:), or put a version in the URL.