SwiftQA Random

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

ConcernMechanism
Fast repeated accessNSCache (memory tier) — cost-based, auto-evicting
Persistence across launchesDisk cache, hashed filenames, size-capped LRU, excluded from backup
Decode cost off main threadPre-decode / downsample before ever reaching the main thread
Memory footprintDownsample via ImageIO to display size, not source resolution
Duplicate concurrent requestsActor-based in-flight request deduplication
Scroll-away cell correctnessTask cancellation tied to cell reuse
Multiple sizes of the same imageCache key includes target size, not just URL
Network-level freshnessURLCache/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.”