Design an image browsing app (a grid of photos you can open full screen): thumbnails, paging, caching, memory and offline.
Predict first
What do the sizes, the byte-budget cache and the prefetch ranges print?
final class CostCache { // LRU cache with a byte budget, not an item count
private(set) var order: [Int] = [] // least recently used first
private var sizes: [Int: Int] = [:]
private(set) var used = 0
let budget: Int
init(budget: Int) { self.budget = budget }
func get(_ id: Int) -> Bool { guard sizes[id] != nil else { return false }; touch(id); return true }
func put(_ id: Int, bytes: Int) {
if let old = sizes[id] { used -= old }
sizes[id] = bytes; used += bytes; touch(id)
while used > budget, let victim = order.first { // evict until we fit again
order.removeFirst(); used -= sizes.removeValue(forKey: victim)!
}
}
private func touch(_ id: Int) { order.removeAll { $0 == id }; order.append(id) }
}
func prefetchRange(visible: ClosedRange<Int>, ahead: Int, count: Int) -> ClosedRange<Int> {
visible.lowerBound...min(count - 1, visible.upperBound + ahead)
}
let decodedFull = 4000 * 3000 * 4 // a 12 megapixel photo decoded to 4 bytes per pixel
let decodedThumb = 200 * 200 * 4
print("full photo:", decodedFull, "bytes, thumbnail:", decodedThumb, "bytes, ratio:", decodedFull / decodedThumb)
let cache = CostCache(budget: 100)
cache.put(1, bytes: 40); cache.put(2, bytes: 40)
print("hit 1:", cache.get(1), "used:", cache.used, "order:", cache.order)
cache.put(3, bytes: 40) // over budget: the least recently used (2) goes
print("hit 2:", cache.get(2), "used:", cache.used, "order:", cache.order)
print("prefetch:", prefetchRange(visible: 10...14, ahead: 5, count: 100), prefetchRange(visible: 96...99, ahead: 5, count: 100))
- full photo: 48000000 bytes, thumbnail: 160000 bytes, ratio: 300 / hit 1: true used: 80 order: [2, 1] / hit 2: false used: 80 order: [1, 3] / prefetch: 10...19 96...99
- ratio: 30 / hit 2: true order: [1, 2, 3]
- prefetch: 10...14 96...104
Mental model. A photo wall where you hang small prints on the wall, keep the big originals in a drawer, and only take one out when someone points at it, always putting back the print nobody has looked at for the longest time when the wall is full.
Reported at Google (anonymous, unverified); it builds on the image pipeline in Q327, so here the focus is the whole app. Start with numbers that drive every decision: a 12-megapixel photo decoded for display is 4000 × 3000 × 4 bytes, about 48 MB, while a 200 × 200 thumbnail is 160 KB, 300 times smaller (the program prints both). So the grid must never decode full-size images: ask the server for size variants (thumbnail, screen-sized, original) or downsample on decode, and load the full image only when one is opened, ideally progressively (show the screen-sized one, then the original if zoomed).
Then the moving parts. Paging: cursor-based pages of a fixed size, fetched when the user nears the end (a low-water mark, as in the swipe deck), with a placeholder grid and stable item ids so a diffable data source updates without jumping. Prefetching: load a few rows ahead of the visible range (UICollectionViewDataSourcePrefetching, or the SwiftUI equivalent), and cancel requests for cells that scroll away. Caches: a memory cache with a byte budget and least-recently-used eviction (the check holds 100 bytes: after touching item 1 and adding item 3, item 2 is evicted and the order is [1, 3]), plus a disk cache of the downloaded bytes; de-duplicate requests for the same URL. Offline: show what is cached, mark the rest, retry on reconnect. Also: accessibility (labels, VoiceOver order), memory warnings (drop the memory cache), and measuring scroll hitches with Instruments. Only the cache and prefetch logic is run here.
A decoded photo is hundreds of times bigger than a thumbnail; the least recently used entry is evicted when the budget is exceeded, and the prefetch range stops at the end of the list.
Common traps
- Decoding the original for a thumbnail cell. A grid of full-size decodes exhausts memory within seconds.
- A cache limited by item count. Ten thumbnails and ten full photos are very different in bytes; budget by cost.
They might follow up with
- How do you keep scrolling smooth? Decode and downsample off the main thread, prefetch ahead, reuse cells and cancel work for cells that left the screen.
- How do you handle a photo that changes on the server? Use a version or ETag in the cache key so a new version is fetched.
Related questions
- 12.7.1 How do you design an image loading pipeline: decoding, downsampling, caching and de-duplication?
- 26.1.1 How do you approach a mobile system design question: what do you clarify, in what order do you design, and…
- 26.5.1 Design the client of a social feed: cursor paging over a re-ranked list, de-duplication, a “new posts” pill…
- 26.8.4 Design the client of a Tinder-style swipe app: card prefetching, instant feel, and swipes that must not be…
← Design the client of a Tinder-style swipe app: card prefetching… Design the rider app of a ride-hailing service: requesting, matching… →