Design an Image Loading Library
Requirements
You build a library that apps use to show remote images, mostly in scrolling lists. Think Kingfisher, Nuke or SDWebImage. The hard parts are memory, speed and cell reuse.
Functional
- Load an image from a URL and show it in a
UIImageViewor a SwiftUI view. - Cache images in memory and on disk.
- Resize the image to the size of the view ("downsampling").
- Cancel the load when a cell is reused for another row.
- Prefetch images before they appear on screen.
- Show a placeholder while loading and a fallback image on error.
Non-functional
- Smooth scrolling at 60/120 fps. Never decode images on the main thread.
- Low memory. A big photo must never crash the app.
- A second load of the same image must be instant (from memory).
- Thread-safe and clean under Swift 6 strict concurrency.
- Small public API. Easy to test with fakes.
Out of scope
- Image editing and filters.
- Animated GIF and video thumbnails (mention them as a follow-up).
- The server or CDN itself.
Questions to ask the interviewer
- Which formats? JPEG, PNG, HEIC, WebP? Animated images?
- UIKit, SwiftUI, or both?
- Can the server return resized images (for example
?w=300)? - How much disk can we use? How long should files live?
- Do some images need auth headers?
- What is the minimum iOS version?
High-level architecture
+---------------+ image(for: request) +--------------------+
| UI layer | --------------------> | ImagePipeline | (actor, the "brain")
| UIImageView | <-------------------- | |
| SwiftUI view | UIImage +---------+----------+
+---------------+ |
+-------------------+-----------------+-------------------+
| | |
v v v
+------------------+ +------------------+ +------------------+
| MemoryCache | | DiskCache | | DataLoader |
| decoded images | | raw bytes as | | URLSession, |
| (NSCache, cost) | | files, LRU | | dedupe |
+------------------+ +------------------+ +--------+---------+
|
v
+------------------+
| Decoder / |
| Downsampler |
| (ImageIO) |
+------------------+The load flow, step by step:
- Memory cache. Look up the key
URL + pixel size. On a hit, show the image right away. - Disk cache. Look up the URL. On a hit, decode and downsample off the main thread.
- Network. On a miss, download. If the same URL is already loading, join that load.
- Store. Save the raw bytes to disk. Decode and downsample. Put the result in memory.
- Deliver. Return the image. The UI sets it on the main thread.
What each part does:
| Part | Job |
|---|---|
ImagePipeline | Runs the flow. Owns the list of in-flight loads. |
MemoryCache | Keeps decoded, ready-to-draw images. Fast, but limited by RAM. |
DiskCache | Keeps the original compressed bytes. Survives app restarts. |
DataLoader | Downloads bytes with URLSession. Checks the status code. |
Decoder | Turns bytes into a small bitmap that fits the view. |
| UI extensions | UIImageView helper and a SwiftUI RemoteImage view. |
Data model
import UIKit
struct ImageRequest: Sendable {
let url: URL
let targetSize: CGSize // in points, the size of the view
let scale: CGFloat // screen scale, for example 3.0
var priority: TaskPriority = .userInitiated
var cacheKey: CacheKey {
CacheKey(url: url,
pixelWidth: Int(targetSize.width * scale),
pixelHeight: Int(targetSize.height * scale))
}
}
struct CacheKey: Hashable, Sendable {
let url: URL
let pixelWidth: Int
let pixelHeight: Int
var maxPixelSize: Int { max(pixelWidth, pixelHeight) }
var string: String { "\(url.absoluteString)#\(pixelWidth)x\(pixelHeight)" }
}
enum ImageError: Error, Sendable {
case badStatus(Int)
case decodeFailed
}Why is the size part of the memory key? The same URL can show as a 40 pt avatar and a 300 pt header. Those are two different bitmaps. The disk key is only the URL, because one original file can make both.
API / interfaces
Server side. The library talks to a normal HTTP server or CDN:
GET https://cdn.example.com/img/{id}?w=300&h=300returns an image already resized by the CDN.- The server sends
Cache-Control: max-age=...andETag. We can use them to set the disk expiry.
Ask for resized images when you can. Downloading a 4000 px photo for a 100 pt thumbnail wastes data and battery.
Swift protocols. Every part is behind a small protocol, so tests can swap in fakes.
protocol ImageLoading: Sendable {
func image(for request: ImageRequest) async throws -> UIImage
}
protocol ImageCaching: Sendable {
func image(for key: CacheKey) -> UIImage? // sync: must be fast
func store(_ image: UIImage, for key: CacheKey)
func removeAll()
}
protocol DataCaching: Sendable {
func data(for url: URL) async -> Data?
func store(_ data: Data, for url: URL) async
}
protocol DataLoading: Sendable {
func data(for url: URL) async throws -> Data
}
protocol ImageDecoding: Sendable {
func decode(_ data: Data, maxPixelSize: Int) throws -> UIImage
}The real loader is small. Turn off URLCache, because we have our own disk cache.
struct URLSessionLoader: DataLoading {
private static let session: URLSession = {
let config = URLSessionConfiguration.default
config.urlCache = nil
config.httpMaximumConnectionsPerHost = 6
return URLSession(configuration: config)
}()
func data(for url: URL) async throws -> Data {
let (data, response) = try await Self.session.data(from: url)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard (200..<300).contains(status) else { throw ImageError.badStatus(status) }
return data
}
}Public UI API. Keep it to one line for the app developer:
imageView.setImage(with: post.photoURL, placeholder: UIImage(named: "placeholder"))
RemoteImage(url: user.avatarURL, size: CGSize(width: 40, height: 40))Deep dives
1. Memory cache: store decoded, small images
A decoded image takes width x height x 4 bytes. The JPEG file size does not matter.
4000 x 3000 photo, decoded = 4000 * 3000 * 4 = 48 MB
Same photo at 300 x 300 pt (@3x) = 900 * 900 * 4 = 3.2 MBSo we cache the downsampled bitmap, and we give each item a "cost" in bytes.
NSCache is a good fit. It is thread-safe and it drops items when memory is low.
final class MemoryCache: ImageCaching, @unchecked Sendable { // NSCache is thread-safe
private let cache = NSCache<NSString, UIImage>()
init(limitBytes: Int = 100 * 1024 * 1024) {
cache.totalCostLimit = limitBytes
}
func image(for key: CacheKey) -> UIImage? {
cache.object(forKey: key.string as NSString)
}
func store(_ image: UIImage, for key: CacheKey) {
cache.setObject(image, forKey: key.string as NSString, cost: image.byteCost)
}
func removeAll() { cache.removeAllObjects() }
}
extension UIImage {
var byteCost: Int {
guard let cg = cgImage else { return 1 }
return cg.bytesPerRow * cg.height
}
}Tips interviewers like:
- Set the limit from the device RAM, for example 10–20% of
ProcessInfo.processInfo.physicalMemory, with a cap. - Also clear the cache on
UIApplication.didReceiveMemoryWarningNotification. - Check the memory cache synchronously in the cell, before you start a
Task. Then cached images show in the same frame, with no flicker.
2. Disk cache: raw bytes, LRU, expiry
On disk we store the original compressed bytes. They are small, and one file can make any size.
- Folder:
Caches/ImageCache. The system may clean it when storage is low. That is fine for a cache. - File name: SHA-256 of the URL. URLs have characters that are not safe in file names.
- Limit: for example 200 MB. When we are over, delete the least recently used files (LRU).
- Expiry: for example 7 days, or
max-agefrom the server. - All file work runs off the main thread. An
actormakes this easy.
import CryptoKit
actor DiskCache: DataCaching {
private let directory: URL
private let limitBytes: Int
private let fm = FileManager.default
init(name: String = "ImageCache", limitBytes: Int = 200 * 1024 * 1024) {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appending(path: name, directoryHint: .isDirectory)
self.limitBytes = limitBytes
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
private func fileURL(for url: URL) -> URL {
let hash = SHA256.hash(data: Data(url.absoluteString.utf8))
return directory.appending(path: hash.map { String(format: "%02x", $0) }.joined())
}
func data(for url: URL) -> Data? {
let file = fileURL(for: url)
guard let data = try? Data(contentsOf: file) else { return nil }
// "Touch" the file so LRU knows it was used.
try? fm.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
return data
}
func store(_ data: Data, for url: URL) {
try? data.write(to: fileURL(for: url), options: .atomic)
}
func trim() {
let keys: [URLResourceKey] = [.contentModificationDateKey, .totalFileAllocatedSizeKey]
guard let files = try? fm.contentsOfDirectory(at: directory,
includingPropertiesForKeys: keys) else { return }
var entries = files.compactMap { file -> (URL, Date, Int)? in
guard let v = try? file.resourceValues(forKeys: Set(keys)),
let date = v.contentModificationDate,
let size = v.totalFileAllocatedSize else { return nil }
return (file, date, size)
}
var total = entries.reduce(0) { $0 + $1.2 }
entries.sort { $0.1 < $1.1 } // oldest first
for (file, _, size) in entries where total > limitBytes {
try? fm.removeItem(at: file)
total -= size
}
}
}Run trim() at launch and when the app goes to the background. Do not run it on every write.
Why not just URLCache? It follows HTTP headers, but you cannot control it well. It may drop
files early, it has no LRU by your rules, and it mixes images with API responses.
Most libraries set urlCache = nil on their image session to avoid caching twice.
3. Deduplication: one download per URL
"Dedupe" means "remove duplicates". Ten cells can ask for the same avatar at the same time. We want one download, not ten. Keep a dictionary of running tasks inside the actor.
actor ImagePipeline: ImageLoading {
static let shared = ImagePipeline(memory: MemoryCache(), disk: DiskCache(),
loader: URLSessionLoader(), decoder: ImageIODecoder())
nonisolated let memory: MemoryCache // NSCache is thread-safe, so the UI can read it sync
private let disk: DiskCache
private let loader: DataLoading
private let decoder: ImageDecoding
private var inFlight: [CacheKey: Task<UIImage, Error>] = [:]
init(memory: MemoryCache, disk: DiskCache, loader: DataLoading, decoder: ImageDecoding) {
self.memory = memory; self.disk = disk; self.loader = loader; self.decoder = decoder
}
func image(for request: ImageRequest) async throws -> UIImage {
let key = request.cacheKey
if let cached = memory.image(for: key) { return cached }
if let running = inFlight[key] { return try await running.value } // join it
let (disk, loader, decoder) = (self.disk, self.loader, self.decoder)
// Detached: decoding must not run on this actor, or all loads would wait in line.
let task = Task.detached(priority: request.priority) {
let data: Data
if let cached = await disk.data(for: request.url) {
data = cached
} else {
data = try await loader.data(for: request.url)
await disk.store(data, for: request.url)
}
return try decoder.decode(data, maxPixelSize: key.maxPixelSize)
}
inFlight[key] = task // set BEFORE the first await, so others can join
defer { inFlight[key] = nil }
let image = try await task.value
memory.store(image, for: key)
return image
}
}Two key points:
- Actor reentrancy. While we
await, other calls can run on the actor. That is why we save the task ininFlightbefore we await it. - Two levels. Real libraries dedupe the download by URL and the decode by URL + size. The code above keys by URL + size, which is simpler. Say this out loud in the interview.
Cancel only when nobody waits. If cell A cancels but cell B still waits, the download must go on. Count the waiters per key. Cancel the shared task only when the count reaches zero.
private struct Entry { let task: Task<UIImage, Error>; var waiters: Int }
private var entries: [CacheKey: Entry] = [:]
private func leave(_ key: CacheKey) { // called from withTaskCancellationHandler
guard var entry = entries[key] else { return }
entry.waiters -= 1
if entry.waiters == 0 {
entry.task.cancel() // nobody wants this image any more
entries[key] = nil
} else {
entries[key] = entry
}
}4. Downsampling and decoding off the main thread
UIImage(data:) is lazy. The real decode happens the first time the image is drawn, on the main
thread. With big images, this drops frames. Fix it in two ways:
- Make the bitmap only as big as the view (downsampling with ImageIO).
- Force the decode now, on a background thread.
import ImageIO
struct ImageIODecoder: ImageDecoding {
func decode(_ data: Data, maxPixelSize: Int) throws -> UIImage {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else {
throw ImageError.decodeFailed
}
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // decode now, not at draw time
kCGImageSourceCreateThumbnailWithTransform: true, // respect EXIF rotation
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else {
throw ImageError.decodeFailed
}
return UIImage(cgImage: cgImage)
}
}ImageIO reads only what it needs. It never makes the full 48 MB bitmap.
On iOS 15+ you can also use UIImage.byPreparingThumbnail(ofSize:) or preparingForDisplay().
5. Cancellation in reused cells and prefetching
Cells are reused. If you do not cancel, a slow old load can finish late and show the wrong image
in a new row. Cancel in prepareForReuse, and check again before you set the image.
final class PhotoCell: UICollectionViewCell {
let imageView = UIImageView()
private var loadTask: Task<Void, Never>?
func configure(url: URL, pipeline: ImagePipeline) {
loadTask?.cancel()
let request = ImageRequest(url: url, targetSize: bounds.size,
scale: traitCollection.displayScale)
if let cached = pipeline.memory.image(for: request.cacheKey) {
imageView.image = cached // sync hit: no flicker
return
}
imageView.image = nil
loadTask = Task { [weak self] in
let image = try? await pipeline.image(for: request)
guard let self, !Task.isCancelled else { return } // cell was reused
self.imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel()
loadTask = nil
imageView.image = nil
}
}In SwiftUI, .task(id:) cancels for you when the view goes away or the URL changes:
struct RemoteImage: View {
let url: URL
let size: CGSize
@Environment(\.displayScale) private var scale
@State private var image: UIImage?
var body: some View {
ZStack {
if let image {
Image(uiImage: image).resizable().scaledToFill()
} else {
Color.secondary.opacity(0.2)
}
}
.frame(width: size.width, height: size.height)
.clipped()
.task(id: url) {
image = nil
let request = ImageRequest(url: url, targetSize: size, scale: scale)
image = try? await ImagePipeline.shared.image(for: request)
}
}
}Prefetching. Use UICollectionViewDataSourcePrefetching. In prefetchItemsAt, start loads with
low priority (.utility). In cancelPrefetchingForItemsAt, cancel them. When the cell appears,
the image is already on disk or in memory.
Why not AsyncImage? It is fine for simple screens. But it has no downsampling, no control over
the cache, and no prefetching. In a long list, it reloads images often.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Memory cache content | Decoded bitmap (fast to draw, big) | Compressed data (small, decode again) | Decoded and downsampled |
| Disk cache content | Original bytes (one file, any size) | Resized bitmaps (fast, many files) | Original; add resized only if decode is slow |
| Disk cache tool | URLCache (free, HTTP rules) | Own files (full control) | Own files |
| Dedupe key | URL only | URL + size | Download by URL, decode by URL + size |
| Cancel on reuse | Cancel the network load | Let it finish into the cache | Cancel only when no one else waits |
| Concurrency | GCD queues + locks | Actors + async/await | Actors (Swift 6) |
What interviewers look for
- You know the decoded size is
width x height x 4, and you downsample to the view size. - Two cache levels with clear roles: memory for decoded images, disk for raw bytes.
- Correct cache keys (size in the memory key, URL only on disk).
- Dedupe of in-flight requests, and you set the task before the first
await(reentrancy). - Decoding off the main thread, with ImageIO.
- Cancellation in
prepareForReuseand a check that the cell still wants this image. - Prefetching with low priority and cancel.
- LRU eviction, size limits, memory warnings.
- Protocols for each part, so you can unit test the pipeline without the network.
- Good follow-ups: animated images, progressive JPEG, auth headers, HEIC vs WebP, failure caching for 404s.