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.
| Context | What it usually means |
|---|---|
| Reads “memory vs disk cache” | Knows the two-tier pattern |
Reads “NSCache” | Has used it for image caches |
Reads “URLCache” | Has 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:
- Memory (
NSCache<NSString, NSObject>) — fast, capped, auto-purged on memory pressure. Holds decoded objects (UIImage, parsed JSON model). - Disk (
FileManager) — slower (milliseconds), persistent across launches, capped by your code. Holds raw bytes (encoded images, JSON, response data). - HTTP (
URLCache) — built intoURLSession. RespectsCache-Control,ETag,If-Modified-Sinceheaders 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:
- 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
}
-
Server-driven (
ETag/If-None-Match): server returns304 Not Modifiedand yourURLCacheserves the cached body. Free if your server supports it. -
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 Protectionwould protect — caches survive backup, sometimes survive uninstall, and may leak across user account contexts on macOS. Tag the cache directory withURLResourceValues.isExcludedFromBackup = trueif 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/AsyncImagedoes 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
- “
UIImage(named:)is cached.” Asset catalog images are cached aggressively. Loose images viaUIImage(contentsOfFile:)are not — every load reads disk and decodes. UseUIImage(named:)or your ownNSCache. - “
URLCacheworks 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. - “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.
- “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.
- “
NSCacheis 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?”
NSCacheis 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. ADictionaryis 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.