Sitemap
Mok5ha G

Mok5ha G

Press enter or click to view image in full size

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

10 min readSep 23, 2026

--

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 UIImage in memory?
  • Should I save images to disk?
  • Is NSCache better than [String: UIImage]?
  • What happens when iOS needs memory?
  • Should I implement my own cache if URLSession already 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 + Disk

But 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.hashValue

for 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...a91

Now the pipeline becomes:

Request
│
▼
Memory?
│
├── YES → return
│
▼
Disk?
│
├── YES → load → memory → return
│
▼
Network
│
▼
Save disk
│
▼
Save memory
│
▼
return

4. 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 image

They all get:

MISS

Then they all check the disk:

MISS

And then…Six network requests.

Request 1 → Network
Request 2 → Network
Request 3 → Network
Request 4 → Network
Request 5 → Network
Request 6 → Network

This 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 ─────────→ Network

The 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
inFlight

Swift Concurrency gives us an elegant tool for protecting that state:

actor ImagePipeline

An 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 / Image

A 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
↓
Network

The other nine calls are:

inFlight[url]

and await the same task. So:

                    ┌── Request 1
│
├── Request 2
│
├── Request 3
│
├── Request 4
│
├── Request 5
│
▼
Task<Data>
│
▼
Network

One 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-Modified

This 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 / Cancellation

For 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 HIT

and:

Cache MISS

at any time. For memory:

NSCache

already 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 caching

These 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 / Image

And 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 image

16. 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:

NSCache

or:

FileManager

The 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
→ Presentation

Once 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 → Network

It’s closer to:

                       ┌──────────────┐
│ SwiftUI View │
└───────┬──────┘
│
▼
┌──────────────┐
│ ImagePipeline│
│ actor │
└───────┬──────┘
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
Memory Cache Disk Cache URLSession
NSCache + URLCache
│ │ │
└─────────────────┼──────────────────┘
│
▼
In-flight Tasks
│
▼
Request Coalescing
│
▼
Image

The best cache is not necessarily the one with the most layers. It’s the one whose behaviour matches the application’s requirements.

--

--