What is the difference between NSCache and Dictionary?

Compare Dictionary and NSCache on one concrete example, an image cache for a photo feed, and show where the two diverge on thread safety, automatic eviction, and key and value rules

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.

swift 5.2
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.

swift 5.2
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. countLimit and totalCostLimit are the policy. They are hints, not hard guarantees: the system may evict earlier, and it may go slightly over a limit before it evicts. The cost is your own number; for images the usual choice is the bytes held in memory.
  • Memory warnings. NSCache reacts 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

AspectDictionary versionNSCache version
Thread safetyYou add it: actor, lock, or barrier queueBuilt in, per call
API shapeawait with an actorSynchronous
EvictionYou write the policy yourselfcountLimit / totalCostLimit
Memory warningsYou observe and clear manuallyHandled internally
KeysAny Hashable, including structsClass types only
ValuesAny type, including structsClass types only
Value can vanishOnly when your code removes itAt any time
Use it forData you must keepData 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.

swift 5.2
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
  • NSCache releases 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.