6.8 — Caching Strategies

Opening scenario

The product manager says: “The feed has to feel instant. Like Instagram instant. Not ‘spinner-for-a-second’ instant — no spinner ever.” You answer: “Then we need to cache.” They say: “Sure, cache everything.” You take a breath. You know “cache everything” is exactly how apps end up holding 4GB of stale data the user pays to back up to iCloud. Caching is not “save bytes.” Caching is deciding what to save, where, for how long, and how to invalidate when the truth changes.

ContextWhat it usually means
Reads “memory vs disk cache”Knows the two-tier pattern
Reads “NSCacheHas used it for image caches
Reads “URLCacheHas tweaked HTTP response caching
Reads “cache-aside”Has built a custom cache layer
Reads “TTL / invalidation”Has been bitten by stale data

Concept → Why → How → Code

Concept

Three caches you’ll usually layer:

  1. Memory (NSCache<NSString, NSObject>) — fast, capped, auto-purged on memory pressure. Holds decoded objects (UIImage, parsed JSON model).
  2. Disk (FileManager) — slower (milliseconds), persistent across launches, capped by your code. Holds raw bytes (encoded images, JSON, response data).
  3. HTTP (URLCache) — built into URLSession. Respects Cache-Control, ETag, If-Modified-Since headers from the server. Free, server-controlled.

The pattern: check memory → check disk → fetch from network → write to disk → decode → write to memory → return.

Why

  • Latency: RAM read is ~100ns, disk read is ~1ms, network round trip is 50–500ms. Three orders of magnitude per tier.
  • Battery: Network requests are 10–100× more expensive than memory access in energy.
  • Offline: Disk cache turns a “no network, show error” into “no network, show what we have.”
  • Cost: Bandwidth costs (server and user) drop dramatically when assets are cached.

How — NSCache for memory

final class ImageMemoryCache {
    private let cache = NSCache<NSString, UIImage>()

    init() {
        cache.countLimit = 200             // max 200 images
        cache.totalCostLimit = 100 * 1024 * 1024  // 100MB
    }

    func image(for key: String) -> UIImage? {
        cache.object(forKey: key as NSString)
    }

    func set(_ image: UIImage, for key: String) {
        let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
        cache.setObject(image, forKey: key as NSString, cost: cost)
    }
}

NSCache automatically evicts on memory warnings — you don’t need to handle UIApplication.didReceiveMemoryWarningNotification yourself. Provide cost so eviction is byte-aware, not just count-aware.

URLCache for HTTP-level caching

let config = URLSessionConfiguration.default
config.urlCache = URLCache(
    memoryCapacity: 50 * 1024 * 1024,        // 50MB RAM
    diskCapacity: 500 * 1024 * 1024,         // 500MB disk
    directory: nil                           // default location
)
config.requestCachePolicy = .useProtocolCachePolicy   // honor server cache headers
let session = URLSession(configuration: config)

If the server sends Cache-Control: max-age=3600, URLSession will serve that response from the cache for the next hour without a network call. If you don’t control the server, override per-request:

var request = URLRequest(url: url)
request.cachePolicy = .returnCacheDataElseLoad   // try cache, fall back to network

The most useful overrides:

  • .useProtocolCachePolicy (default) — honor server.
  • .returnCacheDataElseLoad — offline-friendly.
  • .returnCacheDataDontLoad — strict offline mode.
  • .reloadIgnoringLocalCacheData — force fresh.

A two-tier custom cache

For things URLCache doesn’t handle (decoded images, custom binary blobs, post-processing results):

actor TwoTierCache<Key: Hashable & Sendable, Value: Sendable> {
    private let memory = NSCache<NSString, AnyObject>()
    private let directory: URL
    private let encode: @Sendable (Value) throws -> Data
    private let decode: @Sendable (Data) throws -> Value

    init(name: String,
         encode: @escaping @Sendable (Value) throws -> Data,
         decode: @escaping @Sendable (Data) throws -> Value) throws {
        let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
        directory = caches.appendingPathComponent(name, isDirectory: true)
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        self.encode = encode
        self.decode = decode
        memory.countLimit = 500
    }

    func value(for key: Key) async -> Value? {
        let nsKey = String(describing: key.hashValue) as NSString
        if let cached = memory.object(forKey: nsKey) as? CacheBox<Value> {
            return cached.value
        }
        let url = directory.appendingPathComponent("\(key.hashValue)")
        guard let data = try? Data(contentsOf: url),
              let value = try? decode(data) else { return nil }
        memory.setObject(CacheBox(value: value), forKey: nsKey)
        return value
    }

    func set(_ value: Value, for key: Key) async {
        let nsKey = String(describing: key.hashValue) as NSString
        memory.setObject(CacheBox(value: value), forKey: nsKey)
        let url = directory.appendingPathComponent("\(key.hashValue)")
        if let data = try? encode(value) {
            try? data.write(to: url)
        }
    }

    func clear() {
        memory.removeAllObjects()
        try? FileManager.default.removeItem(at: directory)
        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
    }
}

private final class CacheBox<T> {
    let value: T
    init(value: T) { self.value = value }
}

NSCache doesn’t accept Swift value types directly; wrap them in a CacheBox reference type.

TTL and invalidation

The two hardest problems in computer science: naming, off-by-one errors, and cache invalidation. Three strategies:

  1. TTL (time-to-live): stamp each cached item with an expiration. On read, check Date.now < expiration; if not, treat as miss.
struct CachedItem<T: Codable>: Codable {
    let value: T
    let expiresAt: Date
}

func fetch(key: String) async throws -> Profile {
    if let cached = try? await cache.value(for: key),
       cached.expiresAt > .now {
        return cached.value
    }
    let fresh = try await api.fetchProfile(key)
    let item = CachedItem(value: fresh, expiresAt: .now.addingTimeInterval(300))
    await cache.set(item, for: key)
    return fresh
}
  1. Server-driven (ETag/If-None-Match): server returns 304 Not Modified and your URLCache serves the cached body. Free if your server supports it.

  2. Push invalidation: subscribe to a server event (WebSocket, CloudKit subscription, push notification) that says “key X is stale, drop your cache.” The most accurate, the most complex.

Size caps & eviction

Set a max disk size and a periodic cleanup:

func enforceDiskLimit(maxBytes: Int) {
    let files = (try? FileManager.default.contentsOfDirectory(
        at: directory, includingPropertiesForKeys: [.contentAccessDateKey, .fileSizeKey]
    )) ?? []
    let sized = files.compactMap { url -> (URL, Date, Int)? in
        let values = try? url.resourceValues(forKeys: [.contentAccessDateKey, .fileSizeKey])
        guard let access = values?.contentAccessDate, let size = values?.fileSize else { return nil }
        return (url, access, size)
    }
    let total = sized.reduce(0) { $0 + $1.2 }
    guard total > maxBytes else { return }
    let sortedLRU = sized.sorted { $0.1 < $1.1 }   // oldest first
    var freed = 0
    for (url, _, size) in sortedLRU {
        try? FileManager.default.removeItem(at: url)
        freed += size
        if total - freed <= maxBytes { break }
    }
}

LRU eviction by last-access date. Run this on a background queue every app launch or every 24 hours.

Don’t cache anywhere

  • Sensitive data (auth tokens, personal info, anything covered by privacy reviews) — Keychain only (Chapter 9.2).
  • Anything Data Protection would protect — caches survive backup, sometimes survive uninstall, and may leak across user account contexts on macOS. Tag the cache directory with URLResourceValues.isExcludedFromBackup = true if it shouldn’t sync to iCloud Backup.
var values = URLResourceValues()
values.isExcludedFromBackup = true
try directory.setResourceValues(values)

In the wild

  • SDWebImage and Kingfisher are the de facto third-party image caches for iOS. Both follow the two-tier pattern with NSCache + disk; both have ~25k stars.
  • Apple’s ImageRenderer / AsyncImage does not aggressively cache. For production image-heavy UIs (feeds, grids), most teams roll their own or use Kingfisher.
  • Instagram publishes engineering posts about their image cache (Texture, predictive prefetch, decoded-image cache on a background thread). The strategy: warm the cache before the cell appears.
  • YouTube iOS caches video segments on disk for offline playback and aggressively prefetches ahead of the current playhead.

Common misconceptions

  1. UIImage(named:) is cached.” Asset catalog images are cached aggressively. Loose images via UIImage(contentsOfFile:) are not — every load reads disk and decodes. Use UIImage(named:) or your own NSCache.
  2. URLCache works automatically.” It works if (a) the server sends correct cache headers, (b) the request method is GET, (c) the response isn’t too big for the cache, and (d) the request policy is set to use it. Many APIs fail (a) and you get zero caching by default.
  3. “More cache is always better.” A 4GB disk cache means the user wonders why your app is 4GB in Settings → iPhone Storage. They delete the app. Cap aggressively (50–500MB for most apps); evict by LRU.
  4. “Caching makes things faster.” Caching makes the cache hit path faster. The cache miss path can be slower if you have a complex check-memory-then-disk-then-network ladder. Profile both paths.
  5. NSCache is just a dictionary.” It’s a thread-safe dictionary with auto-eviction on memory pressure. The cost-based eviction means it actually understands “an image is bigger than an integer.”

Seasoned engineer’s take

Caching is the most rewarding investment in app performance you can make, and the most dangerous. The reward: feeds load instantly, offline works without a custom code path, network bills drop. The danger: stale data, user reports of “I added a comment and don’t see it,” and the eternal “logged out but my data is still here” privacy bug.

My rules:

  • Always cache reads, never cache writes. A POST that creates a record shouldn’t be cached — it should hit the server, get a real ID, and then the result lands in your cache.
  • Always have an “invalidate everything” path. On logout, on account switch, on schema migration: nuke every cache. The user will not forgive seeing the previous user’s data.
  • Cache decoded objects in memory, raw bytes on disk. Decoding (JPEG, PDF, parsed JSON) is often as expensive as the network. Keep the post-decode result in NSCache.
  • TTL by data sensitivity. Static reference data (country list): 30 days. User profile: 5 minutes. Anything financial: 0 seconds — always fetch fresh.
  • Tell the user when you’re showing cached data. A discreet “Updated 2 minutes ago” subtitle prevents support tickets.

TIP: Add a debug menu option to clear all caches. You’ll use it every day in development, and you can also expose it (with a confirm) for users who report “app is acting weird” — clearing caches fixes more bugs than most rollbacks.

WARNING: Never store the result of .encrypted(for: user) (or any decrypted PII) in a disk cache without re-encrypting. Disk caches survive iCloud Backup by default, can survive uninstall (Mac sandbox containers persist longer than expected), and may be readable by other apps on a jailbroken device. Decrypt for use, never for storage.

Interview corner

Junior: “What’s the difference between NSCache and Dictionary?”

NSCache is thread-safe, automatically evicts entries under memory pressure, supports cost-based eviction (bytes per entry), and doesn’t hold strong references that prevent objects from being purged. A Dictionary is none of those.

Mid: “Design an image-loading layer for a feed of 1,000 items.”

Three tiers. (1) Memory cache (NSCache<NSURL, UIImage>, costed by decoded byte size, ~100MB cap). (2) Disk cache for encoded bytes (~500MB cap, LRU eviction). (3) Network fetch with cancellation when the cell scrolls off. Decode on a background queue (Task.detached), set the image on main. Cancel in-flight requests on cell reuse. Prefetch a screen ahead based on scroll direction. Verify with Instruments — memory should plateau, not climb.

Senior: “Walk me through a cache invalidation strategy for a CRM app where a sales rep edits a customer record on Device A; Device B should see the change without manual refresh.”

Per-record TTL is too coarse — sales reps see stale data while the TTL window holds. Push invalidation is the answer. Backend pushes a silent APNs notification with the changed record’s ID. App receives, evicts that key from memory and disk caches, and on next access either fetches fresh or, better, includes the new payload in the push and writes it straight to the cache. Combine with optimistic updates: when Device A writes locally, write to its own cache immediately, send to server async, reconcile on response. Mark records “edited locally, awaiting server” so UI can show a sync indicator until the server confirms.

Red flag: “We don’t really cache — the app is supposed to always show fresh data.”

Tells the interviewer the candidate hasn’t considered that “always fetch” means “slow always” and “broken on the train.” Even the freshest-required apps cache rendered cells, fonts, decoded images, asset bundles — caching is an architecture concern, not an optional feature.

Lab preview

The chapter material is woven into Lab 6.3 — Production Network Layer, which wires a memory + disk cache behind the APIClient so the GET endpoints stay fast under repeat access.


Next: Lab 6.1 — Journal App with SwiftData