How do you design image caching?
The layered design — the standard shape
A production image cache is almost always two-tier: an in-memory cache for fast, repeated access, and a disk cache for persistence across launches, sitting in front of the network as the last resort.
1. The memory tier — NSCache, not a plain Dictionary
final class ImageMemoryCache {
private let cache = NSCache<NSString, UIImage>()
init() {
cache.totalCostLimit = 100 * 1024 * 1024
cache.countLimit = 200
}
func image(for key: String) -> UIImage? { cache.object(forKey: key as NSString) }
func store(_ image: UIImage, for key: String, cost: Int) {
cache.setObject(image, forKey: key as NSString, cost: cost)
}
}
NSCache gives automatic eviction under memory pressure, thread safety, and cost-based eviction — a plain dictionary gives you none of these.
2. The disk tier — persistent, with its own size/expiry policy
Hash the cache key to a filename, size-cap with LRU eviction, respect NSURLIsExcludedFromBackupKey (an actual App Store review guideline concern), and store off the main thread.
3. Decoding cost — the part that’s easy to miss
UIImage(data:) decodes lazily on first draw, often on the main thread, causing scroll jank. Production caches force-decode off the main thread before caching using Core Graphics bitmap rendering.
4. Downsampling before caching — memory footprint matters more than raw fidelity
func downsampledImage(from data: Data, to targetSize: CGSize, scale: CGFloat) -> UIImage? {
let options: [CFString: Any] = [kCGImageSourceShouldCache: false]
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else { return nil }
let maxDimension = max(targetSize.width, targetSize.height) * scale
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
kCGImageSourceCreateThumbnailWithTransform: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else { return nil }
return UIImage(cgImage: cgImage)
}
Using ImageIO avoids ever fully decoding the full-resolution image. Often the single biggest lever for reducing memory footprint.
5. Concurrency and request deduplication
actor ImageLoader {
private var inFlightTasks: [URL: Task<UIImage, Error>] = [:]
func loadImage(from url: URL) async throws -> UIImage {
if let existing = inFlightTasks[url] { return try await existing.value }
let task = Task<UIImage, Error> {
defer { inFlightTasks[url] = nil }
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = downsampledImage(from: data, ...) else { throw ImageError.decodingFailed }
return image
}
inFlightTasks[url] = task
return try await task.value
}
}
The actor’s serialized isolation makes “check in-flight, register if new, piggyback if existing” race-free without manual locking.
6. Cancellation for scroll-away cells
class ImageCell: UICollectionViewCell {
private var loadTask: Task<Void, Never>?
func configure(with url: URL) {
loadTask?.cancel()
loadTask = Task {
guard let image = try? await imageLoader.loadImage(from: url) else { return }
guard !Task.isCancelled else { return }
await MainActor.run { self.imageView.image = image }
}
}
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel()
}
}
Without this, fast scrolling causes stale in-flight fetches to race and set images on cells already reused for different content.
7. Cache key design — must account for size/transformation variants
If the same source image is displayed at multiple sizes/crops, caching only by URL causes wrong-size images or repeated downsampling work:
let cacheKey = "\(url.absoluteString)_\(Int(targetSize.width))x\(Int(targetSize.height))"
8. Respecting HTTP caching semantics for network-level freshness
Layered on top of the app-level cache: respecting Cache-Control/ETag headers via URLCache avoids re-downloading genuinely unchanged images, complementing the app-level cache which is about avoiding repeated decode/downsample work.
Interview-ready summary
| Concern | Mechanism |
|---|---|
| Fast repeated access | NSCache (memory tier) — cost-based, auto-evicting |
| Persistence across launches | Disk cache, hashed filenames, size-capped LRU, excluded from backup |
| Decode cost off main thread | Pre-decode / downsample before ever reaching the main thread |
| Memory footprint | Downsample via ImageIO to display size, not source resolution |
| Duplicate concurrent requests | Actor-based in-flight request deduplication |
| Scroll-away cell correctness | Task cancellation tied to cell reuse |
| Multiple sizes of the same image | Cache key includes target size, not just URL |
| Network-level freshness | URLCache/HTTP cache headers, complementing the app-level cache |
One-liner if pressed: “I’d design it as two tiers — NSCache for fast, cost-limited, auto-evicting memory access, and a size-capped LRU disk cache for persistence across launches — with images downsampled to actual display size via ImageIO before ever being cached, decoded off the main thread to avoid scroll jank, request deduplication through an actor so simultaneous requests for the same image don’t trigger redundant fetches, and Task cancellation tied to cell reuse.”