iosNudge

Design an Image Loading Library

HardSystem designPerformanceMemory & ARCConcurrencyNetworking 60 min read

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 UIImageView or 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:

  1. Memory cache. Look up the key URL + pixel size. On a hit, show the image right away.
  2. Disk cache. Look up the URL. On a hit, decode and downsample off the main thread.
  3. Network. On a miss, download. If the same URL is already loading, join that load.
  4. Store. Save the raw bytes to disk. Decode and downsample. Put the result in memory.
  5. Deliver. Return the image. The UI sets it on the main thread.

What each part does:

PartJob
ImagePipelineRuns the flow. Owns the list of in-flight loads.
MemoryCacheKeeps decoded, ready-to-draw images. Fast, but limited by RAM.
DiskCacheKeeps the original compressed bytes. Survives app restarts.
DataLoaderDownloads bytes with URLSession. Checks the status code.
DecoderTurns bytes into a small bitmap that fits the view.
UI extensionsUIImageView 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=300 returns an image already resized by the CDN.
  • The server sends Cache-Control: max-age=... and ETag. 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 MB

So 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-age from the server.
  • All file work runs off the main thread. An actor makes 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 in inFlight before 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:

  1. Make the bitmap only as big as the view (downsampling with ImageIO).
  2. 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

DecisionOption AOption BUsual pick
Memory cache contentDecoded bitmap (fast to draw, big)Compressed data (small, decode again)Decoded and downsampled
Disk cache contentOriginal bytes (one file, any size)Resized bitmaps (fast, many files)Original; add resized only if decode is slow
Disk cache toolURLCache (free, HTTP rules)Own files (full control)Own files
Dedupe keyURL onlyURL + sizeDownload by URL, decode by URL + size
Cancel on reuseCancel the network loadLet it finish into the cacheCancel only when no one else waits
ConcurrencyGCD queues + locksActors + async/awaitActors (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 prepareForReuse and 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.