How to read this. Sections 1 to 8 are the platform-neutral design, with Kotlin/Android code. Section 9 shows what changes on iOS. The pipeline is identical on both; the platform primitives differ.
TL;DR. An image loader is a bounded, cancellable pipeline: memory cache, then disk cache, then network, then decode at the target size. Three ideas carry the answer. Key every cache by (source, target size, transforms), never by URL alone. Coalesce identical in-flight requests so a list of 20 equal avatars does one download and one decode. Cancel work the moment a view is recycled, and downsample on decode so memory is spent on pixels you display, not pixels in the file.
1. Clarify the requirements (about 5 min)
The prompt is deliberately vague. Narrow it before drawing anything:
- A general-purpose library, or a loader inside one app? (Assume a library other teams adopt.)
- Where do images come from? (Assume HTTPS URLs, plus local files and content URIs as a follow-up.)
- Must it bind to views? (Yes: a one-line call that targets an
ImageViewor a Compose image, and handles recycling.) - Formats? (JPEG, PNG, WebP; animated GIF/WebP as a stretch.)
- Offline? (Previously viewed images should still render.)
Functional: load into a target, show placeholder and error images, cache in memory and on disk, cancel, prefetch, transform (crop, round), and invalidate.
Non-functional (the mobile part):
| Concern | Target |
|---|---|
| Scroll | No dropped frames; decode never runs on the main thread |
| Memory | Bounded by bytes, not entry count; shrinks on system signals |
| Data and battery | No duplicate downloads; respect cache headers; cancel unneeded work |
| Correctness | A recycled row never shows the previous row's image |
| Startup | Cached images appear without a network round trip |
Out of scope for now: video thumbnails, image editing, upload. Say this out loud.
2. Public API (about 5 min)
Design the API first, because it forces the lifecycle questions. The shape that Coil and Glide both converge on is a request value object plus a target:
// Sketch of the public surface
val request = ImageRequest.Builder(context)
.data("https://cdn.example.com/a.jpg")
.size(ViewSizeResolver(imageView)) // resolve from the view; fall back to display size
.transformations(RoundedCorners(8.dp))
.placeholder(R.drawable.ph)
.error(R.drawable.err)
.crossfade(true)
.target(imageView) // binding + lifecycle live here
.build()
val handle: Disposable = imageLoader.enqueue(request) // cancel() on recycle
suspend fun execute(request): ImageResult // for non-view callers and tests
fun prefetch(request) // no target: warm the caches
fun trimMemory(level: Int) // host forwards system signals
Points to say out loud:
- A request is a value object. Immutable, comparable, and cheap to build, so a list adapter can create one per bind.
- The target owns binding. The loader writes to the target only if the request is still the target's current request. This is the fix for the recycled-row bug (section 5).
- The host owns lifecycle and the memory budget. The library takes a lifecycle and a memory limit; it does not guess either.
- Inject the loader. Callers depend on an interface, so tests and the app can swap the implementation (constructor injection, Hilt-provided singleton). Call
enqueuefrom the UI layer;prefetchcomes from the UI too, or from a data-layer class, never from a ViewModel that holds aContext.
3. High-level architecture (about 10 min)
Official architecture mapping: the loader sits at the UI layer boundary: Composables and Views call it, ViewModels only expose image URLs in
uiStateand never hold aContextor a View. Its network and disk code are the library's own data sources. Shared in-flight work is app-oriented, running in an injected scope. See UI layer and data layer.
Stage by stage:
- Memory cache. Decoded bitmaps, checked synchronously on the calling thread because the lookup is fast. A hit means no frame is lost to async work.
- In-flight map. Before starting work for a key, check whether someone is already doing it (section 4).
- Disk cache. Encoded bytes (or processed results), checked off the main thread.
- Network. Conditional requests, cache headers, bounded concurrency.
- Decode. Sample down to the target size, off the main thread.
- Transform. Crop, round, blur. Part of the cache key.
Glide's docs describe the same order: it first checks active resources (images already displayed in another view), then the memory cache, then the disk cache. Coil keeps a memory cache of decoded bitmaps and a disk cache for images loaded from the network.
4. Cache keys, coalescing and the memory cache
Cache key design
The key must change whenever the output would change. For memory that is (source, target size, transforms, config). Glide's docs say active-resource, memory and disk keys include the model, width and height, an optional transformation, options, and an optional signature.
- Include size. A 100px thumbnail and a 1080px hero from the same URL are different bitmaps. Without size in the key, you show a blurry hero or waste memory on a huge thumbnail.
- Include transforms. Rounded and plain versions are different results.
- Disk caches can key more loosely. Store the original encoded bytes under the URL key and re-decode per size, or store each processed result. The first costs CPU per decode; the second costs disk. Glide's default for remote images is to store the unmodified original.
- Version via the URL or a signature, not by guessing. If the server replaces an avatar at the same URL, you need a content hash in the URL or an explicit signature, or the cache serves stale pixels until eviction.
Memory LRU and request coalescing (sketch)
data class CacheKey(val source: String, val width: Int, val height: Int, val transforms: List<String>)
class MemoryCache(maxBytes: Int) {
private val lru = object : LruCache<CacheKey, Bitmap>(maxBytes) {
override fun sizeOf(key: CacheKey, value: Bitmap) = value.allocationByteCount // bytes, not entry count
}
fun get(k: CacheKey) = lru.get(k)
fun put(k: CacheKey, b: Bitmap) { lru.put(k, b) }
fun trim(level: Int) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) lru.evictAll()
else if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) lru.trimToSize(lru.size() / 2)
}
}
class Coalescer(private val scope: CoroutineScope) { // injected external scope: app-oriented work that outlives one caller
private val inFlight = HashMap<CacheKey, Deferred<Bitmap>>() // guard with a lock or a single-thread dispatcher
private val waiters = HashMap<CacheKey, Int>()
suspend fun load(key: CacheKey, work: suspend () -> Bitmap): Bitmap {
val job = synchronized(this) {
waiters.merge(key, 1, Int::plus)
inFlight.getOrPut(key) { scope.async { work() } } // first caller starts the work
}
try { return job.await() }
finally {
synchronized(this) {
if (waiters.merge(key, -1, Int::plus) == 0) { // last waiter left: cancel the work
waiters.remove(key); inFlight.remove(key)?.cancel()
}
}
}
}
}
Why this shape:
- Byte-bounded. The Android docs size the cache in bytes by overriding
sizeOfand suggest about one eighth of the app's available memory as an example. Bitmaps vary by orders of magnitude, so an entry-count limit is meaningless. - Work is shared, ownership is counted. One download and one decode serve every waiter. Cancelling one row must not cancel the work for the others, so the work is cancelled only when the last waiter leaves.
- Remove on completion. The map must drop the entry when the work finishes (success or failure), or a failed fetch is cached forever. The sketch omits that for brevity; add a
finallyin the worker. - Do not use weak or soft references as a cache. The Android docs say they are not recommended because the garbage collector clears them aggressively.
5. Views, recycling and cancellation
Lists recycle views. A request for row 3 may finish after the view has been rebound to row 40. The rules:
- Bind a request to the target. Setting a new request on a view cancels the old one. Clearing a view (recycle, detach) cancels the current one.
- Check before delivering. On completion, compare the request ID to the target's current request. If it differs, drop the result; it still goes in the cache.
- Tie to lifecycle. Start on the started state, cancel on destroyed. In Compose, use the composition scope so leaving the composition cancels.
- Resolve the size lazily. A view has no size until it is laid out. Wait for layout rather than loading at full resolution.
- Cancel prefetch for rows scrolled past. Prefetch with lower priority than visible loads.
A loader without cancellation downloads and decodes images for rows no one will see, which costs battery, data and frame time.
6. Decode, downsample and memory pressure
A decoded bitmap costs width x height x bytes per pixel, regardless of the file size. The Android docs give the example of a 2048x1536 image at ARGB_8888 using about 12 MB, and about 0.75 MB at inSampleSize = 4. So:
- Downsample to the target size. Read dimensions first (
inJustDecodeBounds), pick a sample size, then decode.inSampleSizerounds to a power of two, so you land at or above the target, then scale the last step if exact size matters.ImageDecoderwith a target size does the same on newer API levels. - Ask the server for the right size too. A CDN resize parameter saves bytes on the wire as well as in memory. Include it in the key.
- Decode off the main thread, on a bounded dispatcher. Cap concurrent decodes (for example, the CPU count) so a fast scroll cannot spike memory with parallel full decodes.
- Pick the config deliberately.
RGB_565halves memory for opaque photos at a quality cost;HARDWAREbitmaps live in graphics memory but cannot be read back or mutated. - Bitmap pooling. Reusing bitmap memory (
inBitmap) cuts allocation churn and GC pauses during fast scrolls. Glide has aBitmapPoolthat responds toComponentCallbacks2. Pooling is optional complexity; mention it, and note the trade-off: reuse needs matching sizes and careful ownership. - React to memory pressure. The host forwards
onTrimMemory. Evict everything when the process becomes a background candidate, shrink the cache when the UI is hidden, and shrink underRUNNING_LOWorRUNNING_CRITICALsignals. Glide's memory cache and bitmap pool respond toComponentCallbacks2automatically.
7. Network, disk cache and invalidation
Disk cache. An LRU over files with a hard size cap (a fixed byte limit or a percent of free space). Evict oldest-used first. Write to a temp file and rename on success so a killed process never leaves a corrupt entry. All disk work runs off the main thread; the Android docs say disk operations should never run on the UI thread.
HTTP semantics. Do not invent your own expiry when the server already speaks HTTP:
- Honor
Cache-Control: max-ageandExpiresto decide whether to skip the network. - When stale, send a conditional request with
If-None-Match(fromETag) orIf-Modified-Since. A304 Not Modifiedreuses the bytes at the cost of one small round trip. - Coil ships
Cache-Controlhandling in an optional artifact (coil-network-cache-control), which tells you it is a policy decision, not a default everyone wants. - Many CDNs serve images with long
max-ageand content-hashed URLs. In that case URL change is the invalidation mechanism, and the client should do nothing clever.
Explicit invalidation. Expose invalidate(key) and clear() for logout and "profile photo changed" flows. Prefer a signature or version in the key over deleting files.
Network policy. Bounded parallelism per host, retries with backoff for transient errors only, no retry for 404. Skip prefetch on cellular or data saver. Use priorities so visible loads beat prefetch.
Threading model in one line: main thread for memory lookups and delivery, an IO dispatcher for disk and network, a bounded compute dispatcher for decode and transform.
8. Presentation, formats, errors and metrics
- Placeholder, then image. Show the placeholder immediately on bind. A memory hit should skip the placeholder and the fade.
- Crossfade only for async loads. Fading in cache hits looks like flicker in a scrolling list. Coil exposes this as
crossfade(true)on the request. - Progressive loading. Show a low-resolution or blurred preview (a thumbnail URL or a BlurHash-style placeholder) while the full image downloads; progressive JPEGs let the decoder render partial data. Treat both as optional.
- Animated formats (GIF, animated WebP, APNG). Decode frame by frame into a drawable, never into a list of full bitmaps, and pause when off-screen. In Coil these decoders are in a separate optional artifact (
coil-gif), which fits a good library design: keep heavy features out of the core. - Errors. Return a typed result (success with source
MEMORY,DISK,NETWORK; or error with cause). Show the error image, allow tap to retry, and never crash on a corrupt file. Cache negative results briefly to avoid hammering a 404. - Metrics. Memory and disk hit rate, time to first pixel, bytes downloaded, decode time, cancellation rate, and OOM count. Expose an event listener on the loader so the host app can forward them.
- Testability. Make the loader injectable behind an interface, inject dispatchers and a clock, and put the network behind a fake fetcher. Test coalescing with two concurrent requests and one fetch, cancellation with a suspended fetcher, and eviction with a tiny cache.
Failure modes
| Scenario | Handling |
|---|---|
| Row recycled before the load finishes | Cancel on rebind; compare request ID before delivering |
| 20 rows request the same avatar | One in-flight job; waiters share the result |
| One of those rows scrolls away | Count waiters; cancel the job only when the last waiter leaves |
| Huge image (for example, 8000x6000) | Bounds-first decode and sample down; reject or cap above a pixel limit |
| Memory pressure | Trim on onTrimMemory; byte-bounded cache; cap concurrent decodes |
| Same URL, new content | Signature or versioned URL; otherwise ETag revalidation |
| Process killed mid-download | Temp file plus atomic rename; no partial entries |
| Corrupt or unsupported file | Typed error, error drawable, short-lived negative cache |
| Disk full | Evict to the cap; if writes still fail, serve from the network without caching |
9. How to build an image loading library on iOS (Swift)
The design is the same: cache by (source, size, transforms), coalesce in-flight work, cancel on reuse, downsample on decode. Only the primitives change.
| Concern | Android | iOS |
|---|---|---|
| Memory cache | LruCache (byte-sized) | NSCache with totalCostLimit |
| Disk / HTTP cache | Own LRU or OkHttp cache | URLCache, or your own file cache |
| Downsample | inSampleSize / ImageDecoder | ImageIO CGImageSourceCreateThumbnailAtIndex |
| Memory signal | onTrimMemory | didReceiveMemoryWarningNotification |
| Cancellation | Disposable / coroutine Job | Task cancellation, prepareForReuse |
| Pool | BitmapPool / inBitmap | None; rely on ImageIO and system decoding |
| Libraries | Coil, Glide | Nuke, Kingfisher |
What is genuinely different:
- Downsample with ImageIO, not
UIImage.CGImageSourceCreateThumbnailAtIndexwithkCGImageSourceThumbnailMaxPixelSizeproduces a reduced image without building the full-size bitmap first. Without a max pixel size the thumbnail can be as big as the image. SetkCGImageSourceCreateThumbnailFromImageAlways(default false) and, as is commonly shown,kCGImageSourceShouldCacheImmediatelyso decoding is meant to happen on your background thread, not lazily on first draw; addkCGImageSourceCreateThumbnailWithTransformto apply EXIF orientation. Size in pixels, not points: multiply by the screen scale. - Memory signals are coarser. UIKit posts
didReceiveMemoryWarningNotificationand callsdidReceiveMemoryWarning(). The warning carries no levels, so drop most of the cache on warning and when entering the background. NSCacheevicts for you, but Apple says the order is not guaranteed and the cost limit (default 0, meaning none) is not strict, so set one and still clear on warning.URLCachegives you HTTP caching (including conditional requests) with a configured memory and disk capacity, (the default for default sessions is the shared cache; background sessions default to none), so a simple iOS loader can lean on it for the encoded bytes and only cache decoded images itself.- Reuse. In
UICollectionViewcells, cancel inprepareForReuseand check the request token before assigning.
// Sketch: memory cache + coalescing in one actor. Not production code.
struct CacheKey: Hashable {
let url: URL; let pixelSize: Int; let transforms: [String]
var nsKey: NSString { "\(url.absoluteString)|\(pixelSize)|\(transforms.joined(separator: ","))" as NSString } // stable, unlike hashValue
}
actor ImagePipeline {
private let memory = NSCache<NSString, UIImage>()
private var inFlight: [CacheKey: Task<UIImage, Error>] = [:]
private var waiters: [CacheKey: Int] = [:]
func image(for key: CacheKey) async throws -> UIImage {
if let hit = memory.object(forKey: key.nsKey) { return hit }
let task = inFlight[key] ?? Task.detached { try await Self.fetchAndDownsample(key) }
inFlight[key] = task
waiters[key, default: 0] += 1
defer {
waiters[key, default: 1] -= 1
if waiters[key] == 0 { waiters[key] = nil; inFlight[key]?.cancel(); inFlight[key] = nil }
}
let image = try await task.value // actor reentrancy: state is re-read after the await
memory.setObject(image, forKey: key.nsKey, cost: image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0)
return image
}
}
The actor replaces the lock from the Kotlin version: its isolation serializes access to inFlight and waiters. Actors are reentrant, so re-check state after an await. Awaiting task.value does not cancel the shared task if the caller is cancelled; wrap it in withTaskCancellationHandler to release the waiter promptly.
10. What changes by level
- Mid-level: the three-tier pipeline, a byte-bounded LRU memory cache, disk cache, background decode, and cancelling loads for recycled views.
- Senior: cache keys that include size and transforms, request coalescing with waiter counting, downsampling to target size,
onTrimMemoryhandling, HTTP cache headers andETag, and bounded concurrency. They should raise memory and cancellation without being asked. - Staff: the library as a product: a stable API and module boundaries, an interceptor chain for extension, per-surface cache budgets, priorities across screens, metrics and rollout through feature flags, the build-versus-adopt decision (and why you rarely write your own), and the migration plan from an older loader.
Staff answer: the decision and the rollout
Decision: adopt, do not build. Use Coil or Glide on Android and Nuke or Kingfisher on iOS, behind a thin internal wrapper that owns cache budgets, key rules and metrics. Their READMEs describe memory and disk caching, and Coil's adds downsampling and automatic request cancellation. Revisit trigger: a measured need the wrapper cannot express, such as a custom cache-key or transformation rule, or a surface where the library's cost shows up in your profiles. For the build-versus-buy pattern, see the native vs cross-platform decision guide.
Migration from an older loader: ship the wrapper first, move surfaces one team at a time behind a remote flag, and keep the old path until a surface has run clean for a full release cycle.
What to measure: memory-cache hit rate, disk-cache hit rate, time to first pixel per surface, and peak bitmap memory. Guardrail: crash-free rate and out-of-memory kills must not move against the control group; if they do, the flag goes off before anyone debates the cache hit rate.
Rollback on mobile: the flag flips instantly for clients that already have the new code. A store release cannot be recalled from phones that installed it; a Play staged rollout can be halted, but users who already have the update keep it, so the fix is another release. So the old loader stays in the binary during the migration. See the staff mobile loop guide for the rollout loop.
Opinion vs verified: the library descriptions above were read from the project READMEs; the migration order, metrics and guardrails are my opinion, not a documented standard.
11. Follow-up questions to expect
- How would you add animated GIF and WebP? (Frame-by-frame decode into a drawable, pause off-screen, optional module.)
- How do you prevent OOM during a fast fling? (Byte-bounded cache, cap concurrent decodes, downsample, cancel off-screen work, trim on signals.)
- How would you prefetch for a feed? (By scroll position, low priority, skip on cellular, cancel when scrolled past.)
- The server changes an image at the same URL. What happens? (Signature or versioned URL; otherwise
ETagrevalidation; explicitinvalidate.) - How do you test and measure it? (Fake fetcher and dispatchers; hit rate, time to first pixel, bytes, cancel rate, OOM rate.)
Further reading (official docs)
- UI layer (Android): state holders, no
Contextin the ViewModel - Data layer (Android): work lifetimes and main-safety
- Load large bitmaps efficiently (Android)
- Cache bitmaps (Android)
- Manage your app's memory (Android)
- Glide: caching
- Coil: getting started
- Coil: image loaders
- Coil: image requests
- CGImageSourceCreateThumbnailAtIndex (Apple)
- URLCache (Apple)
- NSCache (Apple)
- Responding to memory warnings (Apple)
- Image library interview walkthrough (Grokking Modern Mobile System Design Interview)
Verification notes (last verified 13 Oct 2026)
Android architecture audited against the official guidance on 2026-10-13. Remaining deviations: the official guide says nothing about image loaders, so the UI-boundary placement is inferred from its UI-layer and Context rules; the loader is a library singleton and the sketches omit dispatcher injection and the finally cleanup. Not verified against a fetched primary source: (1) the onTrimMemory level names and values, because the Android memory page fetched did not mention them and the ComponentCallbacks2 reference fetch was truncated; the trim thresholds in MemoryCache.trim are example values. (2) Coil request deduplication, cancellation on detach, lifecycle binding, bitmap pooling and crossfade details: the Coil pages fetched did not state them, so the design is described generically and Coil is cited only for memory/disk caches, the optional coil-network-cache-control and coil-gif artifacts, and the crossfade(true) builder call. Glide's in-flight coalescing was not confirmed either. (3) Verified via the Apple docs JSON: ImageIO option defaults and semantics (MaxPixelSize, FromImageAlways, WithTransform; ShouldCacheImmediately is documented only for the CopyProperties and CreateImageAtIndex calls, so its effect on thumbnails rests on common practice), memory warning delivery, NSCache cost limit and eviction wording, URLCache and session-configuration cache defaults. No numeric URLCache default capacities were sourced, so none are stated. No WWDC session is cited for downsampling. (4) NSCache eviction behavior under pressure, RGB_565 and HARDWARE bitmap trade-offs, ImageDecoder target sizing, inBitmap reuse constraints, and the Android docs' "never on the UI thread" wording is from the cache-bitmap fetch summary and should be re-read. (5) The Swift actor is a sketch, type-checked in Swift 5 and 6 language modes with a stub for the fetch function (no UIImage Sendability error), and the key now uses a stable string instead of hashValue. The iOS sections have not yet been reviewed by an iOS engineer. The Kotlin Coalescer omits removal on completion; the interview expectations in section 2 and the checklist follow the Design Gurus and ProAndroidDev search summaries (the ProAndroidDev page returned 403 and was not read). Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Android SDK 36, with stubs only for app-defined placeholders. The first ImageRequest sketch mixes usage with bare signatures and is not compilable by design, so it is unverified. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders. Added a staff-answer section (section 10) on 2026-10-13; verified against the Coil, Glide, Nuke and Kingfisher READMEs and the Google Play staged-rollout help page; the migration plan and metrics are opinion.
Keep reading
Follow new case studies
Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.
New case studies appear in the feed first. Any RSS reader works.