NSCache

Learn to use NSCache as a memory-sensitive, thread-safe cache for expensive-to-recreate objects, including count limits, cost limits, automatic eviction under memory pressure, and the distinction between a cache and persistent storage.

Some values are expensive to produce but cheap to lose: a decoded UIImage pulled from the network, a parsed JSON response, a computed thumbnail. You want to keep a copy around so the next request for the same thing is instant, but you don't want that copy to be the only copy, and you don't want it to sit in memory forever while the app is under pressure and the operating system is trying to reclaim RAM from every process it can. NSCache exists exactly for this middle ground: a collection that behaves like a dictionary for lookups, but that Foundation is free to empty out — partially or entirely — whenever memory is tight, and that manages its own thread safety so many parts of your app can read and write it concurrently without you adding a single @synchronized block.

Why not just use NSMutableDictionary?

It is tempting to reach for a plain NSMutableDictionary as a cache: set an object for a key, look it up later, done. That works functionally, but it is missing everything that makes a cache safe to use in a real app. A dictionary holds every entry you put into it until you explicitly remove it — there is no concept of "this got too big" or "the system is low on memory, drop some of this." If you cache decoded images in a plain dictionary and never bound it, it will happily grow until it contributes to your app being killed by the operating system's memory watchdog.

NSCache solves this by being eviction-aware. It participates in the same memory-pressure notifications the system uses internally, and by default it will autonomously discard objects — starting, informally, with ones it judges least valuable to keep — when the device is low on memory. Your app keeps running, lookups that would have hit the cache now simply miss and fall through to recomputing or re-fetching the value, and nothing crashes. You get the speed benefit of caching without the risk of caching being the reason you run out of memory.

The second major gap is thread safety. NSMutableDictionary is not safe to mutate from multiple threads at once — concurrent -setObject:forKey: calls from two different queues can corrupt its internal state or crash outright, so a dictionary-backed cache needs you to wrap every access in a lock or a serial queue yourself. NSCache is documented to be safe to access from multiple threads simultaneously without any additional locking on your part, which matters enormously for a cache, since caches are almost always read from and written to from background work (network callbacks, decode queues) at the same time the main thread is reading from them to display something.

• NSMutableDictionary keeps every entry until you remove it manually — no automatic eviction, no memory awareness • NSCache automatically evicts entries under memory pressure, protecting your app from unbounded growth • NSMutableDictionary is not thread-safe for concurrent mutation — you must add your own locking • NSCache is thread-safe out of the box — safe to read and write from multiple queues at once • NSCache is purpose-built for exactly the cache use case; NSMutableDictionary is a general-purpose collection being pressed into service

Cache keys: looser requirements than NSDictionary

NSDictionary and NSMutableDictionary require their keys to conform to NSCopying, because a dictionary copies each key it is given when you insert an entry — this protects the dictionary's internal hashing from a key mutating out from under it after insertion. That copying requirement is a real constraint: it means you cannot, for example, use a mutable object as a dictionary key without either making it immutable first or accepting the copy semantics.

NSCache relaxes this. It does NOT copy the keys you give it — it simply retains them. This means you can use objects that don't conform to NSCopying as NSCache keys, and more importantly, it means you can cache things keyed by object identity: the object reference itself, rather than a value that represents it. A common pattern is keying an NSCache by an NSURL, an NSString identifier, or even a custom object used purely as a token, and NSCache is comfortable with all of these because it is not trying to snapshot the key's value the way NSDictionary is.

In practice, most real caches still use NSString keys (a URL string, a resource identifier, a cache key you construct yourself by combining an identifier with a size or variant) because strings are simple, readable in the debugger, and easy to reason about for equality. The looser requirement matters most when you want to key by an object that doesn't have a natural value-equality representation.

Setting a count limit

The simplest bound you can put on an NSCache is countLimit, an NSUInteger property that caps the number of objects the cache will hold. This is not a hard, enforced ceiling in the sense of rejecting an insert once the limit is hit — NSCache uses it as a hint for when to start evicting older or less-recently-used entries to make room. Setting countLimit to 0 (the default) means no limit based on count; the cache is free to grow until memory pressure forces evictions instead.

A count limit is the right tool when every cached object is roughly the same "size" or "weight" in terms of what you care about — for example, caching at most 50 recently viewed user profile objects, where you don't particularly care about the memory each one occupies, you just want to bound how many are kept alive at once.

Setting a cost limit and per-entry cost

When entries vary wildly in size — the textbook example is images, where a thumbnail might be a few kilobytes and a full-resolution photo might be several megabytes — a count limit is the wrong tool: capping "50 images" tells you nothing about how much memory those 50 images actually consume. For this, NSCache supports a cost model. You set totalCostLimit to a number representing the maximum total "cost" the cache should hold (commonly the number of bytes, though the unit is entirely up to you and just needs to be consistent), and when you insert an object you pass its individual cost using -setObject:forKey:cost: instead of the simpler -setObject:forKey:.

As the sum of costs for all cached entries approaches totalCostLimit, NSCache evicts entries — again, not necessarily strictly least-recently-used, the exact eviction strategy is an implementation detail you should not depend on — to bring the total back under the limit. This lets you say, in effect, "keep roughly this many megabytes of image data cached, and figure out on your own which images stay," which is exactly the shape of the problem an image cache has.

Cost and count limits are not mutually exclusive; you can set both, and the cache respects whichever constraint is hit first. Many real caches only bother with totalCostLimit once entries have meaningfully different sizes, and reserve countLimit for caches of uniform, small objects.

Cache invalidation and NSCacheDelegate

Sometimes you need to remove an entry yourself rather than waiting for the system to decide it should be evicted — the underlying data changed, the user logged out, a cached error response should not be reused. NSCache gives you -removeObjectForKey: for removing a single entry and -removeAllObjects for clearing the cache entirely, both of which you can call any time, from any thread.

NSCache also supports an optional delegate conforming to NSCacheDelegate, which is notified via -cache:willEvictObject: right before the cache evicts an object — whether that eviction happened because of a count/cost limit or because of memory pressure. This is useful for bookkeeping (logging what got evicted, decrementing a counter you're tracking elsewhere) but it is not a hook for stopping the eviction; by the time the delegate method fires, the decision has already been made.

One subtlety worth internalizing: NSCache never guarantees an object will still be there when you ask for it, even a moment after you inserted it, because memory pressure evictions can happen at essentially any time. Your code must always treat a cache lookup as "might be there, might not," and have a real fallback path (recompute it, re-fetch it) for the miss case — never assume that because you just cached something, it is still cached.

The classic use case: caching images and data

The single most common real-world use of NSCache is an in-memory image cache sitting in front of a network fetch. The pattern: before downloading an image for a given URL, check an NSCache keyed by that URL (or a string derived from it); if present, use the cached UIImage immediately and skip the network entirely; if absent, kick off a download (often through something like a Network Client layer, as covered in the "Network Client" project of Module 80 Projects), decode the response into a UIImage, cache it with a cost equal to its approximate byte size, and then display it.

This is exactly the shape of what a component like the "Download Manager" project (also in Module 80 Projects) needs internally: an NSCache layer means the same image scrolled past twice in a table view is only decoded and stored once, and that memory is automatically reclaimed if the user is scrolling through hundreds of images and the app comes under memory pressure — no manual "clear old images" logic required.

The same pattern generalizes beyond images: parsed JSON responses, computed layout metrics, decoded audio buffers — anything that is (a) derived deterministically from some input you can use as a key, (b) expensive enough to recompute that caching is worth it, and (c) safe to lose and regenerate, is a good NSCache candidate.

Cache vs persistent storage — do not confuse the two

It is important to be precise about what NSCache is not: it is not persistent storage. Everything held in an NSCache lives only in memory, only for the lifetime of the process, and can be silently discarded at any point before that. Terminate the app, and every cached entry is gone — there is no disk-backed component to NSCache, and no configuration option that adds one.

If you need data to survive between app launches — a downloaded image the user should see instantly even after a fresh cold start, structured records that represent real application state, anything the user would be upset to see disappear — that belongs in one of the actual persistence mechanisms covered elsewhere in this course: writing files to disk directly (see Persistence: Choosing the Right Storage Tool), or a structured object graph in Core Data. NSCache and those tools are frequently used together in the same feature — an NSCache in front of a Core Data store, for instance, so repeated reads of the same managed object don't repeatedly hit the persistent store — but they solve different problems, and treating NSCache as if it were a lightweight database is a mistake that will eventually surface as data mysteriously "disappearing" for no reason a debugger can explain, because the reason is simply memory pressure or process termination behaving exactly as documented.

Creating and configuring an NSCache

NSCache is generics-annotated here (NSString *, UIImage *) purely for compiler type-checking — it does not change runtime behavior. countLimit and totalCostLimit are both optional; leaving either at 0 means that particular constraint is not enforced. A lookup can return nil at any time, even immediately after insertion, if memory pressure triggered an eviction in between.

NSCache<NSString *, UIImage *> *imageCache = [[NSCache alloc] init];
imageCache.name = @"com.example.imageCache";
imageCache.countLimit = 100;           // at most ~100 entries
imageCache.totalCostLimit = 50 * 1024 * 1024; // ~50 MB of cost units

// Insert with an explicit cost (e.g. approximate byte size)
[imageCache setObject:decodedImage forKey:cacheKey cost:imageByteSize];

// Insert without a cost (cost defaults to 0, only countLimit applies)
[imageCache setObject:decodedImage forKey:cacheKey];

// Lookup — may return nil even for a key you just inserted
UIImage *cached = [imageCache objectForKey:cacheKey];

// Manual invalidation
[imageCache removeObjectForKey:cacheKey];
[imageCache removeAllObjects];

Code Examples

This is the minimal cache-in-front-of-a-fetch pattern: check the cache first, and only fall back to the (presumably slow) network client when there's a miss. countLimit bounds the cache to roughly 50 profiles, which is appropriate here because every UserProfile is roughly the same modest size — there's no need for cost-based accounting. Expected result: First call for a given userID hits the network; every subsequent call for the same userID returns instantly from the cache until it is evicted.

UserProfile *cached = [self.profileCache objectForKey:userID];

Common mistake: Forgetting the early return after a cache hit — calling completion(cached) but then falling through into the network fetch anyway would defeat the entire point of caching and call completion twice.

Rather than counting "how many images," this cache budgets memory directly by estimating each image's decoded footprint (width times height times 4 bytes for RGBA) and passing that as the cost. Foundation evicts entries as needed to stay near the 75 MB totalCostLimit, so a handful of huge full-resolution photos and a hundred tiny thumbnails are all accounted for proportionally to how much memory they actually use. The cost estimate here is approximate — it reflects decoded pixel data, not the compressed file size, which is the number that actually matters once the image is sitting in memory as a UIImage/CGImage.

NSUInteger cost = (NSUInteger)(image.size.width * image.size.height * 4);

This ties the whole picture together: a cache-first lookup keyed directly by NSURL (allowed because NSCache doesn't require NSCopying keys), a fallback through a Network Client-style download, cost accounted by compressed data length, an explicit -invalidateImageForURL: for cases like "the user replaced their avatar and the old cached copy must go now," and an NSCacheDelegate hook purely for observing evictions — never for preventing them.

_cache.delegate = self;

Common mistake: Implementing -cache:willEvictObject: and expecting to be able to "veto" the eviction from inside it — the delegate method is purely informational and runs after the decision to evict has already been made.

Three independent, concurrently-running queues touch the same NSCache instance — two writing, one reading — with zero manual synchronization. This is precisely the property that would be unsafe with a plain NSMutableDictionary, where the same code would risk corrupting internal hash-table state or crashing under contention; NSCache's internal locking makes this a non-issue. Expected result: Read from another queue: <NSData instance or nil, never a crash or corrupted value>

NSData *maybeData = [sharedCache objectForKey:@"resource-1"];

@interface ProfileStore : NSObject
@property (nonatomic, strong) NSCache<NSNumber *, UserProfile *> *profileCache;
@end

@implementation ProfileStore

- (instancetype)init {
  self = [super init];
  if (self) {
    _profileCache = [[NSCache alloc] init];
    _profileCache.countLimit = 50; // keep at most 50 profiles in memory
  }
  return self;
}

- (void)fetchProfileForUserID:(NSNumber *)userID
                    completion:(void (^)(UserProfile *profile))completion {
  UserProfile *cached = [self.profileCache objectForKey:userID];
  if (cached) {
    completion(cached);
    return;
  }

  [self.networkClient fetchProfileForUserID:userID completion:^(UserProfile *profile) {
    if (profile) {
      [self.profileCache setObject:profile forKey:userID];
    }
    completion(profile);
  }];
}

@end
@interface ImageCache : NSObject
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *cache;
@end

@implementation ImageCache

- (instancetype)init {
  self = [super init];
  if (self) {
    _cache = [[NSCache alloc] init];
    _cache.totalCostLimit = 75 * 1024 * 1024; // ~75 MB budget for image data
  }
  return self;
}

- (void)storeImage:(UIImage *)image forURLString:(NSString *)urlString {
  // Approximate the memory cost as width * height * 4 bytes-per-pixel (RGBA)
  NSUInteger cost = (NSUInteger)(image.size.width * image.size.height * 4);
  [self.cache setObject:image forKey:urlString cost:cost];
}

- (UIImage *)cachedImageForURLString:(NSString *)urlString {
  return [self.cache objectForKey:urlString];
}

@end
@interface ImageLoader : NSObject <NSCacheDelegate>
@property (nonatomic, strong) NSCache<NSURL *, UIImage *> *cache;
@property (nonatomic, strong) NetworkClient *networkClient;
@end

@implementation ImageLoader

- (instancetype)init {
  self = [super init];
  if (self) {
    _cache = [[NSCache alloc] init];
    _cache.delegate = self;
    _cache.countLimit = 200;
    _cache.totalCostLimit = 100 * 1024 * 1024;
    _networkClient = [NetworkClient new];
  }
  return self;
}

- (void)loadImageForURL:(NSURL *)url
              completion:(void (^)(UIImage *_Nullable image))completion {
  UIImage *cached = [self.cache objectForKey:url];
  if (cached) {
    completion(cached);
    return;
  }

  [self.networkClient downloadDataAtURL:url completion:^(NSData *data, NSError *error) {
    if (!data) {
      completion(nil);
      return;
    }
    UIImage *decoded = [UIImage imageWithData:data];
    if (decoded) {
      NSUInteger cost = data.length;
      [self.cache setObject:decoded forKey:url cost:cost];
    }
    completion(decoded);
  }];
}

- (void)invalidateImageForURL:(NSURL *)url {
  [self.cache removeObjectForKey:url];
}

#pragma mark - NSCacheDelegate

- (void)cache:(NSCache *)ca
…
NSCache<NSString *, NSData *> *sharedCache = [[NSCache alloc] init];
sharedCache.countLimit = 500;

dispatch_queue_t queueA = dispatch_queue_create("com.example.cacheWriterA", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queueB = dispatch_queue_create("com.example.cacheWriterB", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queueC = dispatch_queue_create("com.example.cacheReaderC", DISPATCH_QUEUE_SERIAL);

dispatch_async(queueA, ^{
  [sharedCache setObject:dataOne forKey:@"resource-1"];
});

dispatch_async(queueB, ^{
  [sharedCache setObject:dataTwo forKey:@"resource-2"];
});

dispatch_async(queueC, ^{
  // Safe even though queueA/queueB may be writing concurrently —
  // no @synchronized, no NSLock, no serial "cache access" queue required.
  NSData *maybeData = [sharedCache objectForKey:@"resource-1"];
  NSLog(@"Read from another queue: %@", maybeData);
});

Cache-first lookup flow

Every cache-backed loader follows this shape: check the cache first; on a hit, return immediately without touching the network or disk; on a miss, load the data from its real source, store it in the cache for next time, then return it.

Request
  ↓
Cache Lookup
 ├── Hit  → Return
 └── Miss
       ↓
    Load Data
       ↓
     Cache
       ↓
     Return

Important Rules

NSCache automatically evicts entries under memory pressure — never assume a cached entry will still be present later, even moments after inserting it.

NSCache does not copy its keys the way NSDictionary does — it retains them, so keys do not need to conform to NSCopying and can be object references.

NSCache is thread-safe for concurrent reads and writes without any additional locking on your part.

Use countLimit when cached entries are roughly uniform in size; use totalCostLimit (with per-entry cost via -setObject:forKey:cost:) when entries vary significantly, like images.

NSCache is memory-only and non-persistent — it is never a substitute for on-disk storage or Core Data, and everything in it is lost on app termination.

NSCacheDelegate's -cache:willEvictObject: is informational only; it cannot prevent or delay an eviction that is already underway.

Where this shows up

NSCache is the standard in-memory layer sitting in front of any expensive-to-produce, safe-to-lose value: decoded images and thumbnails in a scrolling feed, parsed API responses that get requested repeatedly during a session, computed table-view cell heights, and rendered text layouts. It is the piece that makes a Network Client or Download Manager-style component feel instant on the second request instead of re-fetching and re-decoding every single time — while still letting the operating system reclaim that memory the moment the app is under real pressure, with zero manual cleanup code required from you.

// Treating NSCache like a database of record NSCache<NSString *, User *> *userStore = [[NSCache alloc] init]; [userStore setObject:currentUser forKey:@"loggedInUser"]; // ... later, after a memory warning or app relaunch ... User *user = [userStore objectForKey:@"loggedInUser"]; // may be nil!

// Persist anything that must survive memory pressure or relaunch [self persistCurrentUserToDisk:currentUser]; // e.g. via a Persistence layer or Core Data NSCache<NSString *, User *> *userStore = [[NSCache alloc] init]; [userStore setObject:currentUser forKey:@"loggedInUser"]; // fine as a fast-path cache on top

An NSCache entry can vanish at any time under memory pressure, and always vanishes on process termination — it has no durability guarantee whatsoever. Anything that represents real state the app cannot afford to lose (a logged-in user, a document the user is editing) needs an actual persistence layer; NSCache may sit in front of that layer as an optional speed-up, but it can never be the only place the data lives.

// Assuming NSCache needs NSCopying keys like NSDictionary @interface CacheKey : NSObject <NSCopying> // unnecessary ceremony for NSCache @end

// NSCache retains keys rather than copying them — no NSCopying required NSCache<SomePlainObject *, id> *cache = [[NSCache alloc] init]; [cache setObject:value forKey:someObjectReference];

Because NSCache does not copy keys, it has no need for them to implement NSCopying, unlike NSDictionary and NSMutableDictionary. Adding NSCopying conformance purely to satisfy an NSCache is unnecessary work, and can actually be a sign of confusing NSCache's contract with NSDictionary's.

NSMutableDictionary<NSString *, UIImage *> *imageCache = [NSMutableDictionary dictionary]; // written to and read from background download queues with no locking imageCache[urlString] = decodedImage;

NSCache<NSString *, UIImage *> *imageCache = [[NSCache alloc] init]; imageCache.totalCostLimit = 50 * 1024 * 1024; [imageCache setObject:decodedImage forKey:urlString cost:decodedImage.size.width * decodedImage.size.height * 4];

Using a plain dictionary as a concurrently-accessed image cache is unsafe (no built-in thread safety) and unbounded (no automatic eviction, so it can grow until the app is killed for excessive memory use). NSCache solves both problems at once and is the correct tool for exactly this scenario.

[Multiple Choice] What is the primary behavioral difference between NSCache and NSMutableDictionary that makes NSCache suitable as a cache? A. NSCache stores objects in a sorted order B. NSCache automatically evicts entries under memory pressure and is thread-safe without extra locking C. NSCache can only store NSString values D. NSCache is faster at every single lookup than NSMutableDictionary

B. NSCache automatically evicts entries under memory pressure and is thread-safe without extra locking — NSCache's defining advantages are automatic, memory-pressure-aware eviction and built-in thread safety for concurrent access — neither of which NSMutableDictionary provides on its own.

[True / False] NSCache requires its keys to conform to NSCopying, just like NSDictionary does. A. True B. False

B. False — False. NSCache retains its keys rather than copying them, so keys do not need to conform to NSCopying — unlike NSDictionary, which copies keys and therefore requires it.

[Multiple Choice] You are caching images of wildly different resolutions and want the cache to stay within roughly a fixed memory budget. Which NSCache configuration is most appropriate? A. Set only countLimit to a fixed number of images B. Set totalCostLimit and pass each image's byte size via -setObject:forKey:cost: C. Do not configure any limits, since NSCache manages this automatically regardless of size D. Use NSMutableDictionary instead, since NSCache doesn't support size-based limits

B. Set totalCostLimit and pass each image's byte size via -setObject:forKey:cost: — When entries vary significantly in size, a per-entry cost passed through totalCostLimit and -setObject:forKey:cost: is the correct tool — countLimit alone treats every entry as equally 'expensive,' which is wrong when sizes vary widely.

[Multiple Choice] What should you conclude if a value you cached in an NSCache five seconds ago is now missing when you look it up? A. This indicates a bug — NSCache guarantees entries persist until you remove them B. This is expected behavior — NSCache may evict entries at any time, especially under memory pressure C. NSCache entries expire automatically after exactly five seconds D. The key must have been mutated, since NSCache copies keys

B. This is expected behavior — NSCache may evict entries at any time, especially under memory pressure — NSCache offers no durability guarantee for any entry — it may evict anything at any time in response to memory pressure or its own internal limits. Code using an NSCache must always handle a miss gracefully.

[True / False] NSCache is an appropriate replacement for Core Data or on-disk file storage when data needs to survive an app relaunch. A. True B. False

B. False — False. NSCache is memory-only and non-persistent; everything in it is lost when the process terminates. Anything that must survive a relaunch belongs in an actual persistence mechanism like Core Data or direct file storage.

[Multiple Choice] What is the correct interpretation of NSCacheDelegate's -cache:willEvictObject: method? A. It lets the delegate cancel the eviction by returning NO B. It is called after an object has been evicted, giving the delegate a chance to re-insert it C. It is an informational notification called before an already-decided eviction; it cannot prevent it D. It is only called when -removeAllObjects is invoked manually

C. It is an informational notification called before an already-decided eviction; it cannot prevent it — The delegate callback is purely informational. By the time it fires, NSCache has already decided to evict the object; the delegate has no mechanism to veto or delay that eviction.

Build a small "ThumbnailCache" class wrapping an NSCache<NSString *, UIImage *>. It should expose -thumbnailForKey: (returns cached image or nil) and -storeThumbnail:forKey: (stores it with a cost equal to width * height * 4). Configure totalCostLimit to roughly 20 MB. Then write a loadThumbnail(for:completion:) style method that checks the cache first and only falls back to a (stubbed) generateThumbnail: method on a cache miss, storing the result afterward.

@interface ThumbnailCache : NSObject @property (nonatomic, strong) NSCache<NSString *, UIImage *> *cache; @end @implementation ThumbnailCache - (instancetype)init { self = [super init]; if (self) { _cache = [[NSCache alloc] init]; _cache.totalCostLimit = 20 * 1024 * 1024; } return self; } - (UIImage *)thumbnailForKey:(NSString *)key { return [self.cache objectForKey:key]; } - (void)storeThumbnail:(UIImage *)image forKey:(NSString *)key { NSUInteger cost = (NSUInteger)(image.size.width * image.size.height * 4); [self.cache setObject:image forKey:key cost:cost]; } - (void)loadThumbnailForKey:(NSString *)key completion:(void (^)(UIImage *thumbnail))completion { UIImage *cached = [self thumbnailForKey:key]; if (cached) { completion(cached); return; } UIImage *generated = [self generateThumbnailForKey:key]; // stubbed, expensive work if (generated) { [self storeThumbnail:generated forKey:key]; } completion(generated); } @end

The shape should mirror the "Cache Lookup → Hit/Miss" diagram exactly: look up first, return immediately on hit, and only do the expensive work (and then cache it) on a miss.