Building a Production-Ready Image Cache in Modern iOS: Memory → Disk → Network
A practical deep dive into NSCache, disk caching, URLCache, Swift Concurrency, request coalescing, and modern SwiftUI image loading
This is where I started: Memory → Disk → Network. Then I discovered that a production image loader has a few more problems to solve…
Image loading looks simple.
AsyncImage(url: imageURL)Until you build a screen containing 100+ remote images.
Suddenly you start asking:
- Why are images downloaded again when I scroll?
- Why does scrolling feel slow?
- What happens when the same image is requested by 10 cells?
- Should I cache
UIImagein memory? - Should I save images to disk?
- Is
NSCachebetter than[String: UIImage]? - What happens when iOS needs memory?
- Should I implement my own cache if
URLSessionalready supports HTTP caching? - How do I make the cache safe with Swift Concurrency?
This article builds an image-loading pipeline from the ground up and then makes it concurrency-safe.
The basic idea is:
Image Request
│
▼
┌─────────────┐
│ Memory Cache│
└──────┬──────┘
│ miss
▼
┌─────────────┐
│ Disk Cache │
└──────┬──────┘
│ miss
▼
┌─────────────┐
│ HTTP/Network│
└──────┬──────┘
│
▼
Save Memory + DiskBut there is another problem hiding underneath this diagram.
What if 20 requests for the same URL arrive at the same time?
We’ll get there.
1. The naive implementation
Let’s start with something simple.
final class ImageCache {
private let memory =
NSCache<NSString, UIImage>()
func load(from url: URL) async -> UIImage? {
let key = url.absoluteString
// Memory
if let image = memory.object(
forKey: key as NSString
) {
return image
}
// Network
guard let (data, _) = try? await
URLSession.shared.data(from: url),
let image = UIImage(data: data)
else {
return nil
}
memory.setObject(
image,
forKey: key as NSString
)
return image
}
}This already avoids repeated network requests while the image remains in memory. But it has limitations. If the app’s memory pressure evicts cached objects, the next request goes straight back to the network. That’s where disk caching comes in.
2. Level 1 — Memory Cache
For the fastest path, we can use:
NSCache<NSString, UIImage>Why NSCache instead of:
var images: [String: UIImage] = [:]Because NSCache is specifically designed for temporary cached objects.
Apple documents that NSCache supports automatic eviction when resources are low, and its cache operations can be performed from multiple threads without manually locking the cache.
That makes it much more appropriate for an in-memory cache than treating a normal dictionary as a cache.
Think of it as:
Dictionary
↓
"I own these objects."
NSCache
↓
"Keep these objects if the system has room."The important word is temporary.You should never assume:
memory.object(forKey: key)Will always succeed.
3. Level 2 — Disk Cache
Memory is fast. But memory is not persistent.So we add disk storage.
let cacheDirectory =
FileManager.default
.urls(
for: .cachesDirectory,
in: .userDomainMask
)[0]
.appendingPathComponent("ImageCache")Create the directory:
try? FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)Then create a deterministic cache key. I would avoid:
url.hashValuefor persistent filenames. Instead, use a stable representation of the URL, such as a SHA-256 digest.
Conceptually:
https://example.com/avatar.jpg
↓
SHA-256
↓
8c7f...a91
↓
ImageCache/8c7f...a91Now the pipeline becomes:
Request
│
▼
Memory?
│
├── YES → return
│
▼
Disk?
│
├── YES → load → memory → return
│
▼
Network
│
▼
Save disk
│
▼
Save memory
│
▼
return4. But there is a hidden concurrency problem
Imagine a SwiftUI LazyVStack displaying 30 images. The same image appears in several places. At approximately the same time:
Cell 1 ─────┐
Cell 2 ─────┤
Cell 3 ─────┤
Cell 4 ─────┼──→ load(sameURL)
Cell 5 ─────┤
Cell 6 ─────┘Every request checks:
if memory contains imageThey all get:
MISSThen they all check the disk:
MISSAnd then…Six network requests.
Request 1 → Network
Request 2 → Network
Request 3 → Network
Request 4 → Network
Request 5 → Network
Request 6 → NetworkThis is called a duplicate in-flight request problem. Caching alone doesn’t solve it. We need request coalescing.
5. Request coalescing with Swift Concurrency
Instead of only storing completed images, we can also store requests currently in progress. Conceptually:
private var inFlight: [URL: Task<Data, Error>] = [:]Now the first request creates the task:
URL A
↓
Task A ─────────→ NetworkThe next five requests don’t create new network calls. They simply await:
Request 2 ──┐
Request 3 ──┤
Request 4 ──┼──→ Task A
Request 5 ──┤
Request 6 ──┘One request. Six consumers. That’s much better.
6. Why an actor?
Now we have shared mutable state:
memory
disk
inFlightSwift Concurrency gives us an elegant tool for protecting that state:
actor ImagePipelineAn actor protects its isolated mutable state from concurrent access.
This is particularly useful when building an image pipeline because multiple SwiftUI tasks may request images concurrently.
Swift’s concurrency model also uses Sendable to express values that can safely cross concurrency domains.
7. A modern image pipeline
Instead of making the cache directly responsible for UIImage, I prefer to keep the cache/network layer working with Data.
That gives us a cleaner separation:
ImagePipeline
│
├── Memory Data
├── Disk Data
├── Network Data
└── In-flight Tasks
│
▼
Data
│
▼
UIImage / ImageA simplified implementation:
actor ImagePipeline {
private let memory =
NSCache<NSString, NSData>()
private var inFlight:
[URL: Task<Data, Error>] = [:]
private let diskURL: URL
init() {
diskURL = FileManager.default
.urls(
for: .cachesDirectory,
in: .userDomainMask
)[0]
.appendingPathComponent("ImageCache")
try? FileManager.default.createDirectory(
at: diskURL,
withIntermediateDirectories: true
)
}
func imageData(
from url: URL
) async throws -> Data {
let key = url.absoluteString as NSString
// 1. Memory
if let cached = memory.object(forKey: key) {
return cached as Data
}
// 2. Disk
let fileURL = diskURL
.appendingPathComponent(
cacheKey(for: url)
)
if let data = try? Data(contentsOf: fileURL) {
memory.setObject(
data as NSData,
forKey: key
)
return data
}
// 3. Existing request
if let task = inFlight[url] {
return try await task.value
}
// 4. Create one network request
let task = Task<Data, Error> {
let (data, response) =
try await URLSession.shared.data(
from: url
)
guard
let httpResponse =
response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode
else {
throw URLError(.badServerResponse)
}
try data.write(
to: fileURL,
options: .atomic
)
return data
}
inFlight[url] = task
do {
let data = try await task.value
memory.setObject(
data as NSData,
forKey: key
)
inFlight[url] = nil
return data
} catch {
inFlight[url] = nil
throw error
}
}
private func cacheKey(
for url: URL
) -> String {
url.absoluteString
.data(using: .utf8)!
.base64EncodedString()
.replacingOccurrences(
of: "/",
with: "_"
)
}
}This is still intentionally simplified for an article.
A production implementation should additionally consider:
- cache size
- expiration
- disk cleanup
- image decoding
- cancellation
- HTTP response headers
- corrupted cache files
- authentication
- request priority
- progressive images
- retry policy
But the architecture is now much more interesting.
8. What happens now with 10 simultaneous requests?
Suppose:
await pipeline.imageData(from: url)is called ten times. The first call:
Memory ❌
Disk ❌
inFlight ❌
Create Task
↓
NetworkThe other nine calls are:
inFlight[url]and await the same task. So:
┌── Request 1
│
├── Request 2
│
├── Request 3
│
├── Request 4
│
├── Request 5
│
▼
Task<Data>
│
▼
NetworkOne network request. Multiple consumers.
This is the part that turns a basic image cache into an actual image loading pipeline.
9. What about HTTP caching?
Here’s where things get interesting. We don’t necessarily need to manually implement everything.
Get Mok5ha G’s stories in your inbox
Join Medium for free to get updates from this writer.
URLSession works with URLCache, which provides both in-memory and on-disk caching for URL responses. Apple also documents that HTTP response headers and the request/session cache policy influence whether a response is cached. For example:
let configuration =
URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 64 * 1024 * 1024,
diskCapacity: 256 * 1024 * 1024
)
let session =
URLSession(configuration: configuration)Then:
let (data, response) =
try await session.data(from: url)The HTTP layer can decide whether the response can be reused based on things such as:
Cache-Control
ETag
Expires
Last-ModifiedThis is fundamentally different from simply saying:
“I downloaded this image once, therefore keep it forever.”
The server gets to participate in cache validity.
10. SwiftUI’s AsyncImage is also evolving
This is particularly relevant with current SwiftUI. Apple’s WWDC26 material describes an important change for the Xcode 27 / 2027 OS releases: AsyncImage supports standard HTTP caching by default and respects server cache headers. Apple also provides APIs to supply a custom URLRequest and custom URLSession, including a custom URLCache.
For example, Apple demonstrates configuring a session with:
let configuration =
URLSessionConfiguration.default
configuration.urlCache = URLCache(
memoryCapacity: 64 * 1024 * 1024,
diskCapacity: 256 * 1024 * 1024
)
let session =
URLSession(configuration: configuration)And using a custom request/session with AsyncImage. So the question isn’t:
“Should I always build my own image cache?”
It’s:
“What caching behavior does my application actually need?”
11. When is AsyncImage enough?
For a simple screen:
AsyncImage(url: imageURL)may be completely appropriate. Especially when:
- you don’t need custom cache rules
- standard HTTP caching is sufficient
- you don’t need request deduplication logic
- you don’t need custom image processing
- you don’t need offline-first behavior
- you don’t need detailed cache metrics
Don’t build infrastructure just because you can.
12. When would I build a custom image pipeline?
A custom pipeline becomes more interesting when the application needs things like:
ImagePipeline
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Memory Disk Network
│ │ │
└───────────────┼────────────────┘
│
Request Coalescing
│
Image Processing
│
Metrics / Logging
│
Retry / CancellationFor example:
Social feed
Hundreds of images can be requested while scrolling.
E-commerce
Product thumbnails may appear repeatedly across multiple screens.
Messaging
The same avatar may appear in hundreds of cells.
Offline-first applications
Previously downloaded images should remain available without connectivity.
13. Cache eviction
A cache isn’t a database. That’s an important mental model. You should be able to tolerate:
Cache HITand:
Cache MISSat any time. For memory:
NSCachealready provides system-aware eviction behavior. Apple specifically describes it as a temporary cache whose contents may be evicted when resources are constrained. For disk, you need your own strategy. For example:
Maximum disk size
↓
500 MB
Current cache
↓
530 MB
Remove oldest / least recently used files
↓
Target
↓
400 MB
You could track:
struct CacheMetadata {
let url: URL
let size: Int
let lastAccessDate: Date
}and periodically remove older entries.
14. Thread safety vs actor isolation
This is another interview question that often gets mixed up.
NSCache
Apple documents NSCache as safe to access from multiple threads without manually locking cache operations.
Dictionary
A mutable dictionary does not automatically give you that protection.
If multiple execution contexts mutate shared mutable collections without synchronisation, you can create data races. Apple documents this class of problem and recommends coordinating access to mutable collections.
Actor
An actor gives you another level of protection:
actor ImagePipeline {
private var inFlight: [URL: Task<Data, Error>] = [:]
}The actor isolates the mutable state. So you can think of the responsibilities as:
NSCache
↓
Memory cache eviction + cache synchronization
Actor
↓
Isolation of your pipeline's mutable state
URLSession / URLCache
↓
HTTP/network response cachingThese solve related but different problems.
15. The final architecture
After all these considerations, our image loading architecture becomes:
SwiftUI View
│
▼
ImagePipeline
(actor)
│
┌───────────┼───────────┐
▼ ▼ ▼
Memory Disk Network
NSCache Files URLSession
│ │ │
└───────────┼───────────┘
│
inFlight Tasks
│
▼
Request Coalescing
│
▼
Image Data
│
▼
UIImage / ImageAnd the lookup order is:
1. Memory
↓ miss
2. Disk
↓ miss
3. Existing in-flight request
↓ none
4. Network / HTTP cache
↓
5. Save disk
↓
6. Save memory
↓
7. Return image16. The interview questions this architecture answers
Once you understand this design, you can answer several common senior iOS interview questions.
Q: Why NSCache instead of a dictionary?
NSCache is intended for temporary cached objects, supports automatic eviction under memory pressure, and provides thread-safe cache operations.
Q: What happens if memory is full?
The memory cache can evict objects. The application should treat memory cache entries as disposable and fall back to disk/network.
Q: How do you prevent downloading the same image 10 times?
Maintain an inFlight dictionary:
[URL: Task<Data, Error>]The first request creates the task. Subsequent requests await the same task.
Q: How do you make the cache concurrency-safe?
Use Swift Concurrency isolation, such as an actor, for mutable state.
Alternatively, use appropriate synchronisation primitives when actor isolation isn’t suitable. Swift’s Sendable model is also relevant when values cross concurrency domains.
Q: Does URLSession already cache images?
It can cache HTTP responses through URLCache, subject to request/session policies and HTTP caching rules.
Q: So why build a custom image cache?
When you need behaviour beyond standard HTTP caching — for example custom memory/disk policies, request coalescing, offline behavior, image transformations, metrics, or application-specific cache rules.
Q: Should every app implement this?
No. For many SwiftUI applications, Apple’s built-in image loading and HTTP caching may be sufficient. Current SwiftUI releases are also improving AsyncImage's caching capabilities.
The engineering decision should come from the application’s requirements, not from the desire to build a more complicated abstraction.
17. The bigger lesson
The interesting part of image caching isn’t really:
NSCacheor:
FileManagerThe interesting part is understanding responsibility.
Memory
→ Fast temporary access
Disk
→ Longer-lived local storage
HTTP Cache
→ Protocol/server-driven freshness
URLSession
→ Network transport
Actor
→ Concurrency isolation
inFlight Tasks
→ Request deduplication
SwiftUI
→ PresentationOnce these responsibilities are separated, the architecture becomes easier to reason about. And that’s the difference between:
“I know how to cache an image.”
and:
“I can design an image-loading system.”
Final takeaway
A production-minded image pipeline isn’t simply:
Memory → Disk → NetworkIt’s closer to:
┌──────────────┐
│ SwiftUI View │
└───────┬──────┘
│
▼
┌──────────────┐
│ ImagePipeline│
│ actor │
└───────┬──────┘
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
Memory Cache Disk Cache URLSession
NSCache + URLCache
│ │ │
└─────────────────┼──────────────────┘
│
▼
In-flight Tasks
│
▼
Request Coalescing
│
▼
ImageThe best cache is not necessarily the one with the most layers. It’s the one whose behaviour matches the application’s requirements.








