How do you design an image loading pipeline: decoding, downsampling, caching and de-duplication?
Predict first
A 400×200 image is thumbnailed with a 100-pixel maximum. What sizes come back?
import Foundation
import ImageIO
import CoreGraphics
import UniformTypeIdentifiers
let ctx = CGContext(data: nil, width: 400, height: 200, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
ctx.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: 400, height: 200))
let out = NSMutableData()
let dest = CGImageDestinationCreateWithData(out, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
CGImageDestinationFinalize(dest)
let src = CGImageSourceCreateWithData(out as CFData, [kCGImageSourceShouldCache: false] as CFDictionary)!
let options: [CFString: Any] = [kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: 100]
let thumb = CGImageSourceCreateThumbnailAtIndex(src, 0, options as CFDictionary)!
print(thumb.width, thumb.height)
- 100 50
- 100 100
- 400 200
Predict first
Two concurrent requests for the same key go through an actor that tracks in-flight tasks. How many fetches happen?
import Foundation
actor Loader {
var inflight: [String: Task<Int, Never>] = [:]
var fetches = 0
func load(_ key: String) async -> Int {
if let t = inflight[key] { return await t.value }
let t = Task { () -> Int in try? await Task.sleep(nanoseconds: 20_000_000); return 1 }
inflight[key] = t
fetches += 1
let v = await t.value
inflight[key] = nil
return v
}
}
let ld = Loader()
async let a = ld.load("a")
async let b = ld.load("a")
_ = await (a, b)
print(await ld.fetches)
- 1
- 2
Mental model. Shipping a poster to hang as a postage stamp: ask the printer for a stamp-sized copy rather than unrolling the poster in the hallway, and remember which ones you have already printed.
Think of the pipeline as stages, each with its own cost and cache. 1) Fetch the bytes with URLSession: honour HTTP caching, cancel when the cell leaves the screen, and prioritise visible images. 2) Decode: a JPEG or PNG on disk is compressed; to display it the system must decode it to a bitmap, and UIKit does that lazily, on the main thread at first draw if you let it, which causes scrolling hitches. Decode off the main thread, ahead of time. 3) Downsample: a decoded bitmap costs width × height × 4 bytes regardless of file size, so a 4000×3000 photo is about 48 MB in memory to show in a 100-point thumbnail. Use ImageIO to produce a thumbnail directly from the file data without ever decoding the full image: CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize (the target size in pixels, so multiply points by the screen scale), kCGImageSourceCreateThumbnailFromImageAlways, kCGImageSourceShouldCacheImmediately (so the decode happens on your thread) and kCGImageSourceCreateThumbnailWithTransform (to respect EXIF orientation). A 400×200 source asked for a 100-pixel maximum comes back 100×50, and the original’s dimensions are readable from the source properties without decoding.
4) Cache in layers: a small memory cache of decoded, downsampled images keyed by URL and target size (NSCache, which evicts under pressure; see the Foundation topic for design), a disk cache of the downloaded bytes (or of resized versions) and the HTTP cache. Include the size in the key, otherwise a thumbnail is returned where a full image is wanted. 5) De-duplicate in-flight requests: when ten cells ask for the same URL at once, one download should serve all, which an actor with a dictionary of in-flight Tasks does (two concurrent requests for the same key trigger one fetch). 6) Cell integration: cancel the load on reuse and verify the cell still wants the image when the result arrives, to avoid the wrong-image bug. Use progressive placeholders (a blurhash or dominant colour), serve the right size from the server or CDN (resize parameters, WebP or HEIC/AVIF where supported), set long cache lifetimes on versioned URLs, and measure memory in Instruments. Do not hand-roll it if a mature library fits (Nuke, Kingfisher, SDWebImage, or AsyncImage for simple needs), but know what each stage does.
The longer side is scaled to 100 pixels and the aspect ratio is kept, and the full image is never decoded.
The second caller finds the first one’s task in the dictionary and awaits it, so only one fetch is made.
Common traps
- Decoding the full-size image to show a thumbnail. It uses tens of megabytes and does the work on the main thread; downsample from the data.
- Caching by URL alone. A small version can be returned where a large one is needed; include the size in the key.
They might follow up with
- Why is a 2 MB JPEG a 48 MB problem? Memory holds the decoded pixels: width × height × 4 bytes.
- How do you stop ten cells downloading the same image? Track in-flight tasks by key in an actor and share the result.