A clear breakdown of the four types of image caching: in-memory, disk, database, and distributed, and how iOS's NSCache makes in-memory caching surprisingly powerful when you understand it.
Every iOS app that shows images has a caching problem. Not because it’s complicated, but because getting it wrong hits you in all the obvious places, too many network requests, janky scrolling, memory warnings you didn’t plan for.
The whole point of caching is simple: smooth experience, low network overhead. Images appear fast, the server isn’t hammered, and memory stays reasonable. But caching isn’t one thing. It’s a few different strategies, each with its own trade-offs, and picking the wrong one for the wrong situation causes real problems.
Images live in RAM. This is the fastest option you have, there’s no file system, no network, just a lookup in memory. Access is near-instant.
The downside is it’s volatile. Close the app, kill the process, or have the OS reclaim memory under pressure, and everything in the cache is gone. No persistence across restarts.
You also have to actively manage it. RAM is shared across everything on the device, so you need to think about how big the cache is allowed to grow and what its lifecycle looks like. Leave it unchecked and your app becomes the reason the user’s phone gets slow.
Good for: images the user is actively looking at right now. The current feed, avatars on screen, anything that needs to be instant. Don’t expect it to survive a restart.
Images are written to the file system. They stick around on the device even after the app closes, so next time the user opens the app, the image is already there without hitting the network.
The cost is speed. Disk is slower than RAM, and while modern iPhones have closed the gap a lot, you’ll still notice it in a tight scroll loop if you’re not careful about when you read from disk versus memory.
Disk caching also means you’re managing files, which means you need two things in place:
Most people reach for Kingfisher or SDWebImage and let the library handle all of this. If you’re rolling your own, you need both. TTL without size limits means files pile up forever, size limits without TTL means you might be serving stale content.
Images, or more often their raw binary data, stored in SQLite, Core Data, or Realm.
This one’s situational. It makes sense when images aren’t standalone, when they have relationships to other data, need to be queryable, or have to work offline as part of a larger sync model. Think an offline-capable app where a product image is tied to a product record and needs to live in the same transaction.
The management is genuinely robust and offline support is solid. But the complexity cost is real: schema design, migrations, query overhead. For plain image caching it’s overkill. For complex datasets where images are deeply tied to structured data, it earns its place.
Images served from multiple servers spread across geographic locations, usually via Redis or Memcached behind a CDN. The idea is that the image is served from whichever edge node is closest to the user, not from a single origin.
As a mobile engineer you’re almost always consuming a distributed cache, not building one. But it’s worth understanding because it affects decisions you do make, how you handle CDN URLs, what cache-control headers you agree on with your backend team, whether to cache a remote URL on device or trust the CDN to be fast enough.
iOS gives you NSCache out of the box, and it’s honestly quite good once you understand how it works.
let imageCache = NSCache<NSString, UIImage>()
What makes it different from just using a dictionary:
It responds to memory pressure automatically. When the system is running low on memory, NSCache starts evicting objects on its own. You don’t need a didReceiveMemoryWarning handler manually clearing things, the OS handles it.
It’s thread-safe. Read and write from multiple threads without any locking on your end. No serial queue, no os_unfair_lock. In a feed app where you’re decoding images on background threads and accessing the cache from the main thread, this matters a lot.
No manual locking. If you’ve ever wrapped a plain dictionary in a DispatchQueue to make it thread-safe, you know how easy it is to get wrong. NSCache handles that internally.
NSCache keys have to be reference types, they conform to AnyObject. Two practical options:
// Option 1: NSString, cleanest for URL-based keys
imageCache.setObject(image, forKey: urlString as NSString)
// Option 2: Custom NSObject subclass if you need richer key logic
final class CacheKey: NSObject {
let value: String
init(_ value: String) { self.value = value }
override var hash: Int { value.hash }
override func isEqual(_ object: Any?) -> Bool {
(object as? CacheKey)?.value == value
}
}
imageCache.setObject(image, forKey: CacheKey(urlString))
For most image caches keyed by URL, NSString via bridging is all you need. The custom subclass is useful if you have composite keys or need custom equality behaviour.
NSCache lets you set a totalCostLimit and assign a cost per entry. When the total cost is exceeded, entries get evicted automatically.
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
let cost = Int(image.size.width * image.size.height * 4)
imageCache.setObject(image, forKey: urlString as NSString, cost: cost)
Cost is arbitrary, you decide what it means. For images, bytes in memory is the obvious choice. A 1000x1000 image at 4 bytes per pixel is roughly 4 MB. Set a sensible limit and the cache manages itself within it.
final class ImageCache {
static let shared = ImageCache()
private let cache: NSCache<NSString, UIImage> = {
let c = NSCache<NSString, UIImage>()
c.totalCostLimit = 50 * 1024 * 1024
return c
}()
private init() {}
func image(for url: URL) -> UIImage? {
cache.object(forKey: url.absoluteString as NSString)
}
func store(_ image: UIImage, for url: URL) {
let cost = Int(image.size.width * image.size.height * 4)
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
}
}
Intentionally minimal. Thread-safe, cost-limited, single shared instance. In a real app you’d layer disk caching underneath it, check memory first, fall back to disk, then network.
Honestly, most apps just need two layers: memory for speed, disk for persistence.
The flow is straightforward. Check NSCache first, if the image is there it’s instant. If not, check disk, slower but avoids a network call. If not on disk, fetch from the network and write to both. On next launch, disk is warm. Memory fills back up as the user scrolls.
Database caching only makes sense when images are part of a complex data model. Distributed caching is mostly a backend concern, though knowing how it works helps you have better conversations with your backend team about headers and invalidation.
And eviction is the thing everyone forgets. NSCache handles itself under memory pressure, but you still need to call removeAllObjects() on logout. For disk, do a TTL sweep on launch, delete anything older than your threshold. If you’re not thinking about removal, the cache will grow forever and you’ll eventually get a bug report you didn’t expect.
The sign of a good caching setup is that nobody notices it. Images appear, scroll is smooth, memory stays flat. It just works. The bad ones are invisible too, until suddenly the app feels slow and nobody can tell you why.
If you want to go deeper on iOS 27 specific changes and updated practices around image caching, this article covers it in detail: SwiftUI Image Caching: What’s Changed in iOS 27