Answer
The core idea
Dictionary is a Swift value type for storing key-value pairs. It keeps everything you put in it until your own code removes it, and it does nothing about threads. NSCache is a Foundation class built for one job: caching. It is thread-safe, and it is allowed to throw values away on its own when the system needs memory back.
The clearest way to see the difference is to write the same ImageCache twice.
The short interview answer is: a Dictionary is storage you control, and NSCache is storage the system can empty at any moment. Use Dictionary when losing a value is a bug, and NSCache when losing a value only means doing the work again.
1. ImageCache built on a Dictionary
A plain final class wrapping a dictionary would not be safe here: images are downloaded on background threads while cells read the cache during configuration, so reads and writes can overlap. That is a data race, and it can corrupt the dictionary's storage, not just return a stale value. An actor removes that risk, because the compiler guarantees only one task is inside it at a time.
actor ImageCache {
private var storage: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
storage[url]
}
func store(_ image: UIImage, for url: URL) {
storage[url] = image // ❌ Safe now, but nothing ever removes an entry
}
}Two things follow from this version.
The API changed. Every read is now await cache.image(for: url), so a cell cannot check the cache synchronously while it is being configured. A serial queue, a lock, or a concurrent queue with .barrier writes would keep the call synchronous, but the synchronization code is still yours to write.
It still never gives memory back. In an Instagram-style feed the user keeps scrolling, the feed keeps loading new photos, and every decoded image stays in storage forever. Dictionary is a container, not a cache: it does not know these values are cheap to recreate, and it never hears the system. iOS sends a memory warning, the dictionary ignores it, and eventually the app is terminated for using too much memory. Making it a real cache means writing your own size limit, removal order, and memory-warning observer.
2. ImageCache built on NSCache
Here is the same cache on top of NSCache.
final class ImageCache {
private let cache = NSCache<NSURL, UIImage>()
init() {
cache.countLimit = 200 // keep at most 200 images
cache.totalCostLimit = 50 * 1_024 * 1_024 // and roughly 50 MB of them
}
func image(for url: URL) -> UIImage? {
cache.object(forKey: url as NSURL) // ✅ safe from any thread, and still synchronous
}
func store(_ image: UIImage, for url: URL, cost: Int) {
cache.setObject(image, forKey: url as NSURL, cost: cost)
}
}Everything the first version was missing is already inside the class:
- Thread safety. Both methods can be called from any thread or task. No actor, no lock — and the API stays synchronous.
- Eviction.
countLimitandtotalCostLimitare the policy. They are hints, not hard guarantees: the system may evict earlier, and it may go slightly over a limit before it evicts. Thecostis your own number; for images the usual choice is the bytes held in memory. - Memory warnings.
NSCachereacts to memory pressure itself and drops objects when the system asks for memory back.
The price is one rule you must accept: a read can return nil at any time, even right after a write. Every caller has to be able to recreate the value.
3. The two versions side by side
| Aspect | Dictionary version | NSCache version |
|---|---|---|
| Thread safety | You add it: actor, lock, or barrier queue | Built in, per call |
| API shape | await with an actor | Synchronous |
| Eviction | You write the policy yourself | countLimit / totalCostLimit |
| Memory warnings | You observe and clear manually | Handled internally |
| Keys | Any Hashable, including structs | Class types only |
| Values | Any type, including structs | Class types only |
| Value can vanish | Only when your code removes it | At any time |
| Use it for | Data you must keep | Data you can recreate |
One caveat applies to both: thread-safe means each call is safe, not that a sequence of calls is atomic. If ten cells miss the same image at the same moment, all ten start a download. Fixing that needs request deduplication on top of the cache — see How do you make shared mutable state thread-safe in Swift?.
4. NSCache keys and values must be classes
NSCache is declared as NSCache<KeyType: AnyObject, ObjectType: AnyObject>, so both the key and the value must be reference types. That is why the second version writes url as NSURL while the actor version could use URL directly. A Dictionary only asks the key to be Hashable, so a struct key works there and would not compile with NSCache.
In practice you use bridged Foundation classes as keys: NSString, NSURL, NSNumber. For a custom key, use a class that overrides hash and isEqual(_:), and keep it immutable — NSCache retains its keys instead of copying them, so a key that changes after insertion can make its own entry unreachable.
5. What NSCache does not do
- No persistence. It lives in memory only. Everything is gone after the app restarts.
- No iteration. There is no way to list the keys or read the current size.
- No documented eviction order. It is not a strict LRU, so do not depend on which entry goes first.
- Not a source of truth. Drafts, user settings, and offline data belong in a file,
UserDefaults, or a database.
6. Where each one belongs
In a real feed NSCache is the fast memory layer, disk is the slower layer that survives restarts, and the network is the last resort.
final class ImageCache {
private let memory = NSCache<NSURL, UIImage>() // auto-evicts under memory pressure
private let disk: DiskCache // persists across launches
init(disk: DiskCache) {
self.disk = disk
}
func image(for url: URL) async throws -> UIImage {
let key = url as NSURL
// Level 1: memory — instant, no I/O
if let cached = memory.object(forKey: key) {
return cached
}
// Level 2: disk — slower, but survives app restarts
if let diskImage = await disk.image(for: url) {
memory.setObject(diskImage, forKey: key) // promote back into memory
return diskImage
}
// Level 3: network — only when both caches miss
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let image = UIImage(data: data) else {
throw URLError(.badServerResponse)
}
memory.setObject(image, forKey: key)
await disk.store(image, for: url)
return image
}
}Memory answers "did I show this image a second ago?". Disk answers "did I show it yesterday, or am I offline right now?". The network answers "I have never seen this image".
7. Why it matters
- an unsynchronized dictionary cache can crash under concurrent access, and those crashes are hard to reproduce because they depend on timing
- a dictionary cache also grows without limit, so memory keeps rising while the user scrolls, and iOS eventually terminates the app
NSCachereleases that memory during a memory warning, so the app stays alive and only pays for re-decoding a few images- treating a cache as a source of truth produces blank cells, lost user input, and bugs that appear only when the device is under pressure
Interview angle
Offer to write the cache both ways, because the comparison is the answer. Start with the Dictionary version: it needs an actor or a lock before it is safe at all, it turns every read into an await, and it still never evicts, so you would have to add a limit and a memory-warning observer yourself. Then show the NSCache version and point out that both gaps are already closed, at the price of class-only keys and values and a read that may return nil at any time. Close with the rule that decides it: use Dictionary when losing a value is a bug, NSCache when losing a value only costs work. The Apple documentation for NSCache is the reference for the eviction and key-retention behavior.