Design an Image Loading Library
A complete mobile-first breakdown of how to build Coil or Glide from scratch — covering the 3-tier cache hierarchy, LRU eviction, Bitmap pooling, request deduplication, lifecycle awareness, and the full decode pipeline.
This question is deceptively hard. On the surface it sounds like "implement Glide" — but interviewers are really testing whether you understand memory management, cache design, concurrency, and Android lifecycle at a deep level. Start by scoping it carefully.
The key insight: loading an image is a 5-step pipeline — Request → Cache Check → Network Fetch → Decode → Display. Every design decision is about making this pipeline faster, cheaper, and safer.
- Load an image from a URL and display it in an
ImageViewor ComposeAsyncImage - Cache images at two levels — memory (fast) and disk (persistent across launches)
- Support placeholder images while loading and error images on failure
- Video / GIF support
- Image transformations (blur, circle crop) — mention as extensible, not core
- SVG / vector format support
- Cross-fade animations between placeholder and result
- Zero main thread blocking — all I/O and decode must happen on background threads
- Memory efficient — no OOM crashes; Bitmap memory must stay bounded
- Lifecycle aware — cancel in-flight requests when the view or Activity is destroyed
- No duplicate network requests — if two views request the same URL simultaneously, fetch only once
- P99 latency guarantees (cache hit should be <1ms, but we won't guarantee it contractually)
- Cross-process image sharing
A strong opening move: draw the 3-tier cache before anything else. It's the backbone of every image loading library and immediately signals you know the domain.
Every image loading library — Glide, Coil, Picasso, Fresco — is built around the same three-level cache. The library checks each level in order; on a miss it falls through to the next. The decision about what lives at each level is the most important design choice.
Decoded Bitmaps are expensive to create (CPU cost for decompression) but fast to draw. Storing them in memory gives sub-millisecond display. But Bitmaps are large — a 1080×1920 ARGB_8888 image is 8MB. Disk cache stores the compressed JPEG/PNG bytes instead (~100KB), which is 80× smaller. On a disk cache hit, you still pay the decode cost, but you avoid the network round-trip. This is the right trade-off for mobile.
Before drawing the architecture, align on the six components you'll be designing:
- ImageLoader — the public API entry point. Accepts a request and orchestrates the pipeline.
- MemoryCache — an LRU map of
CacheKey → Bitmap. Size-bounded by system RAM. - DiskCache — an LRU file store (using
DiskLruCache). Stores compressed image bytes keyed by URL hash. - Fetcher — downloads bytes from the network using OkHttp. Handles HTTP caching headers.
- Decoder — converts raw bytes into a
BitmapusingBitmapFactory. Handles downsampling to target size. - BitmapPool — a pool of reusable
Bitmapobjects to reduce GC pressure. Glide's killer feature.
Every load(url) call flows through this pipeline. The key rule: check cheapest source first, fall through to expensive sources only on a miss.
The memory cache is an LRU (Least Recently Used) map bounded by total Bitmap byte size (not count). Android's LruCache provides this out of the box. The typical sizing is 15% of the available app heap.
// ─── MemoryCache ─────────────────────────────────────────────── class MemoryCache(maxSizeBytes: Int) { private val lruCache = object : LruCache<CacheKey, Bitmap>(maxSizeBytes) { override fun sizeOf(key: CacheKey, bitmap: Bitmap): Int = bitmap.byteCount // size in bytes, not count! override fun entryRemoved(evicted: Boolean, key: CacheKey, old: Bitmap, new: Bitmap?) { if (evicted) bitmapPool.put(old) // recycle into pool, don't GC it } } fun get(key: CacheKey): Bitmap? = lruCache.get(key) fun put(key: CacheKey, bitmap: Bitmap) = lruCache.put(key, bitmap) // Sizing: use ~15% of available heap companion object { fun defaultSize(): Int { val memClass = (context.getSystemService(ActivityManager::class.java) .memoryClass) // in MB return (memClass * 1024 * 1024) / 7 // ~14% of heap } } }
⚠️ Common mistake: Sizing the cache by count (e.g. "hold 50 images") is wrong — a thumbnail is 10KB and a full-screen image is 8MB. Always size by total bytes. Glide, Coil, and Picasso all do this.
When the memory cache evicts a Bitmap, instead of letting the GC collect it, recycle it into a pool. The next decode operation can borrow a same-sized Bitmap from the pool and decode directly into it using BitmapFactory.Options.inBitmap. This eliminates the most expensive GC pauses in image-heavy apps.
// Decode with inBitmap to reuse memory from the pool fun decode(bytes: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap { val options = BitmapFactory.Options() // Pass 1: measure dimensions without allocating options.inJustDecodeBounds = true BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) // Calculate inSampleSize to avoid loading full-res into memory options.inSampleSize = calculateSampleSize(options, targetWidth, targetHeight) options.inJustDecodeBounds = false // Try to borrow a reusable Bitmap from pool options.inMutable = true options.inBitmap = bitmapPool.get( options.outWidth / options.inSampleSize, options.outHeight / options.inSampleSize, options.outConfig ?: Bitmap.Config.ARGB_8888 ) // may be null — BitmapFactory allocates fresh if so // Pass 2: actual decode, reusing pool Bitmap if available return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) }
Loading a 4K photo into a 200×200 thumbnail slot without downsampling wastes 200× the memory. Always calculate inSampleSize based on the target view dimensions before decoding. A value of 2 halves each dimension (quarter the pixels); 4 gives one-sixteenth the pixels. Coil does this automatically — it's one of the core reasons it outperforms naive implementations.
In a RecyclerView with 50 cells loading the same avatar image simultaneously, a naive implementation fires 50 network requests for the same URL. The fix: maintain a map of in-flight requests keyed by URL. If a second request arrives for the same URL, attach it to the existing in-flight job rather than starting a new one.
// Engine — deduplicates concurrent requests for the same key class Engine { private val activeRequests = ConcurrentHashMap<CacheKey, Deferred<Bitmap>>() suspend fun load(key: CacheKey, request: ImageRequest): Bitmap { // Memory cache hit — return immediately memoryCache.get(key)?.let { return it } // Deduplicate: attach to existing job if already in-flight return activeRequests.getOrPut(key) { coroutineScope.async(Dispatchers.IO) { try { val bitmap = fetchDecodeSave(key, request) memoryCache.put(key, bitmap) bitmap } finally { activeRequests.remove(key) // clean up when done } } }.await() } }
If a user scrolls past a cell before its image loads, the in-flight request must be cancelled to avoid wasting bandwidth and updating a view that's now off screen (or recycled to show a different item). The solution: tie each request to the target's lifecycle using a LifecycleObserver or Compose's DisposableEffect.
// ─── Cancellation tied to View lifecycle ────────────────────── fun ImageView.load(url: String, imageLoader: ImageLoader) { // Cancel any previous request on this view getTag(R.id.image_loader_tag)?.let { (it as? Job)?.cancel() } val job = imageLoader.coroutineScope.launch { val bitmap = imageLoader.execute(ImageRequest(url, this@load)) withContext(Dispatchers.Main) { setImageBitmap(bitmap) } } setTag(R.id.image_loader_tag, job) } // ─── Compose version ─────────────────────────────────────────── @Composable fun AsyncImage(url: String, ...) { var bitmap by remember { mutableStateOf<Bitmap?>(null) } LaunchedEffect(url) { // re-launches when URL changes; cancels previous bitmap = imageLoader.execute(ImageRequest(url)) } bitmap?.let { Image(it.asImageBitmap(), ...) } }
Three precise flows that interviewers drill into. Be able to draw and explain each one.
Draw this in the first 5 minutes. It shows the full request lifecycle — happy path (memory hit), disk hit, and full network miss — on a single diagram.
The worst-case path: nothing in any cache. This covers every component in the system.
CacheKey from URL + target dimensions. Without dimensions, a 400×400 and a 200×200 decode of the same URL would incorrectly share a cache entry.Dispatchers.IO. OkHttp respects HTTP caching headers (ETag, Cache-Control). Response body streamed into the disk cache write stream.inJustDecodeBounds) to get dimensions. Calculates inSampleSize. Borrows reusable Bitmap from BitmapPool. Pass 2 decodes into that Bitmap.The happy path — the same image was loaded recently and the Bitmap is still in memory.
CacheKey.When the memory cache is full and a new Bitmap needs to be inserted, the LRU entry is evicted. Critically, this evicted Bitmap is not handed to the GC — it goes into the BitmapPool for reuse.
LruCache.entryRemoved() callback fires with the evicted Bitmap. Instead of calling bitmap.recycle(), pass it to BitmapPool.BitmapFactory.Options.inBitmap is set — the decoder writes directly into the existing allocation.Glide maintains two memory tiers: an Active Resources map (WeakReferences to Bitmaps currently shown on screen) and the LRU MemoryCache. When a view releases a Bitmap (scrolled off screen), it moves from Active Resources to the LRU cache. This prevents a displayed Bitmap from being evicted just because it's LRU — a subtle but important correctness detail.
GIFs are a sequence of frames. Store the raw GIF bytes in the disk cache (same as JPEG). On decode, use Android's ImageDecoder API (API 28+) or a custom frame decoder for older versions. The result is not a Bitmap but an AnimatedImageDrawable or a Movie object. The Target interface needs to accept Drawable instead of just Bitmap. Memory cost is frames × frame_size — apply strict size limits for GIF cache entries.
Add a Transformation interface to the pipeline: fun transform(pool: BitmapPool, input: Bitmap): Bitmap. Each transformation is applied after decode, before caching. Include the transformation class name in the CacheKey — a circle-cropped and a plain version of the same URL must be separate cache entries. Transformations can borrow from and return to the BitmapPool to stay allocation-free.
Placeholders are set synchronously on the main thread before the async pipeline starts — they must be resource IDs, not URLs. When the pipeline completes (success or error), replace the placeholder with the result on the main thread. Use a CrossFadeDrawable to animate the transition. Error drawables follow the same pattern but are set in the catch block.
OkHttp handles this automatically if you configure it with a cache directory. For Cache-Control: max-age=3600, OkHttp serves from its HTTP cache without hitting the network. Your disk cache and OkHttp's cache serve different purposes — OkHttp caches compressed bytes with HTTP semantics (ETag, conditional GET); your disk cache is optimised for offline access with LRU eviction. You can use both in tandem or let OkHttp's cache replace your disk cache for simpler setups.
Use RecyclerView.addOnScrollListener to detect scroll direction and velocity. When the user is scrolling down, prefetch the next N item URLs that are about to enter the viewport. Issue imageLoader.preload(url, targetSize) — which runs the full pipeline (fetch + decode + cache) without setting the result on any view. When the cell actually comes into view, it gets a memory cache hit. Coil calls this MemoryCache.prefetch(); Glide has a ListPreloader helper.
The single most common production crash in Android image loading is an OutOfMemoryError. The system calls onTrimMemory(level) to warn you before killing the process — you must respond or face an OOM kill. This is the edge case interviewers at FAANG probe hardest.
| Level | Meaning | Action |
|---|---|---|
UI_HIDDEN (20) | App backgrounded, UI not visible | Clear entire memory cache (views can't see images anyway) |
RUNNING_MODERATE (5) | Low memory, still foreground | Trim memory cache to 50% |
RUNNING_LOW (10) | Memory is low, GCs imminent | Trim memory cache to 25%, clear BitmapPool |
RUNNING_CRITICAL (15) | App about to be killed | Clear memory cache + BitmapPool entirely |
COMPLETE (80) | Process in background LRU, no memory left | Clear all — memory cache, pool, any transient state |
// Register in Application.onCreate() class ImageLoader(...) : ComponentCallbacks2 { override fun onTrimMemory(level: Int) { when { level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { // App backgrounded — clear the whole memory cache memoryCache.evictAll() } level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { // Critically low while foregrounded — clear cache + pool memoryCache.evictAll() bitmapPool.clearMemory() } level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { // Low — trim to 25% and clear pool memoryCache.trimToSize(memoryCache.maxSize() / 4) bitmapPool.clearMemory() } level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { // Moderate — trim to 50% memoryCache.trimToSize(memoryCache.maxSize() / 2) } } } override fun onLowMemory() { // Legacy callback (API <14) — treat as TRIM_MEMORY_COMPLETE memoryCache.evictAll() bitmapPool.clearMemory() } }
⚠️ Not registering ComponentCallbacks2 is the #1 source of background OOM kills. Android cannot tell you freed memory if you never gave it a callback. Glide, Coil, and Picasso all register this in their AppGlideModule / ImageLoader.Builder setup.
Even with a correctly sized cache, a single very large image can trigger OOM during decode. The defensive strategy: wrap BitmapFactory.decodeByteArray in a try/catch for OutOfMemoryError, then retry with a doubled inSampleSize.
fun decodeSafely(bytes: ByteArray, targetW: Int, targetH: Int): Bitmap? { var sampleSize = calculateSampleSize(bytes, targetW, targetH) repeat(3) { attempt -> try { val opts = BitmapFactory.Options().apply { inSampleSize = sampleSize inMutable = true inBitmap = bitmapPool.get(targetW / sampleSize, targetH / sampleSize, Bitmap.Config.ARGB_8888) } return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) } catch (e: OutOfMemoryError) { sampleSize *= 2 // halve each dimension, retry // attempt 0→1→2 then give up } } return null // show error drawable }
This is the most subtle correctness bug in any image loader. A RecyclerView cell is bound to item A (starts loading image A), then quickly scrolls off screen and is rebound to item B (starts loading image B). If image A finishes loading after image B's request started, the cell would show A's image in B's slot. The fix requires checking that the view's intended URL still matches before setting the bitmap.
view.tag.view.tag and call job.cancel(). Step 2: set view.tag = urlB (the new expected URL). Launch new coroutine for urlB.setImageBitmap, check view.tag == urlA. It's now urlB → drop silently. ✓// Correct implementation with tag guard fun ImageView.load(url: String, loader: ImageLoader) { // 1. Cancel previous job for this view (getTag(R.id.image_loader_tag) as? Job)?.cancel() // 2. Tag the view with the EXPECTED url BEFORE launching setTag(R.id.image_loader_url, url) val job = loader.scope.launch { val bitmap = loader.execute(ImageRequest(url)) withContext(Dispatchers.Main) { // 3. Guard: only set if URL hasn't changed while we were loading if (getTag(R.id.image_loader_url) == url) { setImageBitmap(bitmap) } // else: view was rebound — silently drop this result } } setTag(R.id.image_loader_tag, job) } // In Compose: LaunchedEffect(url) handles this automatically // The key change (url) cancels the previous effect — no tag needed @Composable fun AsyncImage(url: String) { var bitmap by remember { mutableStateOf<Bitmap?>(null) } LaunchedEffect(url) { // url is the key — changes cancel & relaunch bitmap = loader.execute(ImageRequest(url)) } }
A cache stampede happens when many coroutines simultaneously discover the same disk cache miss and all try to fetch + write the same key at once. This causes N redundant network requests and N concurrent disk writes to the same file — corrupting the entry. The deduplication map in the Engine handles the in-flight case, but what about the moment between "key not found in disk" and "first write completes"?
Use a ConcurrentHashMap<CacheKey, Mutex> to ensure only one coroutine fetches each key at a time. All others wait on the mutex and then get a cache hit.
// Disk cache write guard — prevents simultaneous writes for same key private val writeGuards = ConcurrentHashMap<CacheKey, Mutex>() suspend fun fetchWithGuard(key: CacheKey, url: String): ByteArray { // Fast path: disk cache already has it diskCache.get(key)?.let { return it } // Slow path: acquire per-key mutex before fetching val mutex = writeGuards.getOrPut(key) { Mutex() } mutex.withLock { // Double-check after acquiring lock — another coroutine may have written it diskCache.get(key)?.let { return it } // We are the winner — fetch and write val bytes = okHttp.get(url) diskCache.put(key, bytes) // atomic: write temp → rename return bytes } // Cleanup guard entry to avoid memory leak .also { writeGuards.remove(key) } }
The double-check pattern (check again after acquiring lock) is critical. Without it, the second coroutine would fetch from network even though the first one just wrote the file. This is the same double-checked locking pattern used in thread-safe singletons.
| Approach | Handles in-flight duplication | Handles disk race | Complexity |
|---|---|---|---|
| Nothing | ✗ N requests | ✗ Corrupt writes | Simple |
| activeRequests map only | ✓ | ✗ Race on first request | Low |
| activeRequests + mutex per key | ✓ | ✓ | Medium |
| Coil / Glide engine | ✓ (built-in) | ✓ (DiskLruCache) | Done for you |
Android 8.0+ introduced Bitmap.Config.HARDWARE — a Bitmap whose pixel data lives in GPU memory rather than the JVM heap. This is a significant performance win for display, but comes with sharp restrictions that trip up many engineers in interviews.
| Aspect | ARGB_8888 (software) | HARDWARE |
|---|---|---|
| Pixel memory location | JVM heap | GPU memory (not in heap) |
| Draw to Canvas (HW accelerated) | ✓ | ✓ Faster — no copy needed |
| Draw to software Canvas (e.g. PDF) | ✓ | ✗ Throws exception |
Read pixels (getPixels()) | ✓ | ✗ Throws exception |
Use as inBitmap in BitmapPool | ✓ | ✗ Not mutable |
| Apply Canvas-based transformations | ✓ | ✗ Must copy to software first |
| GC / heap pressure | High (8MB per image) | Zero heap impact |
// Coil: allowHardware controls this per-request val request = ImageRequest.Builder(context) .data(url) .allowHardware(true) // default — use HARDWARE config for display .target(imageView) .build() // When you need to draw on a Canvas (PDF export, blur transform): val request = ImageRequest.Builder(context) .data(url) .allowHardware(false) // force ARGB_8888 — readable by CPU .build() // Custom decoder: detect and downgrade hardware bitmaps for transforms fun ensureSoftware(bitmap: Bitmap): Bitmap { if (bitmap.config == Bitmap.Config.HARDWARE) { // Copy to software — this IS a heap allocation, warn if hot path return bitmap.copy(Bitmap.Config.ARGB_8888, false) } return bitmap }
Use allowHardware(true) (the default) for all standard display uses — it halves the heap pressure and speeds up GPU compositing. Set allowHardware(false) only when you need CPU access: saving to file, applying Canvas-based transformations (blur, circle crop), or sharing the pixel data with a native library. Don't blanket-disable hardware bitmaps — it's a significant regression for apps loading many images.
- 2-tier cache (memory + disk) explained
- LRU eviction policy understood
- No main-thread blocking
- Placeholder + error image support
- Can describe inSampleSize downsampling
- BitmapPool and inBitmap reuse
- Request deduplication with in-flight map
- Lifecycle-aware cancellation + tag guard
- CacheKey includes target dimensions
- Active Resources vs LRU distinction
- onTrimMemory levels + appropriate response
- Transformation pipeline design
- RecyclerView prefetching strategy
- Cache stampede / mutex-per-key pattern
- Hardware Bitmap trade-offs
- OkHttp HTTP cache vs disk cache trade-offs
- Defensive OOM retry with doubled inSampleSize
The most frequently asked follow-ups in real Image Loading Library design interviews. Know each one cold.
Tier 1 — Memory Cache: stores decoded Bitmap objects in RAM. Sub-millisecond access. Sized to ~15% of app heap. Evicts via LRU. Tier 2 — Disk Cache: stores compressed image bytes (JPEG/PNG) as files. 5–50ms access. Sized to ~250MB. Evicts via LRU across files. Tier 3 — Network: the origin server. 200ms–3s. The library checks each tier in order and writes the result back to cheaper tiers on a miss.
Decoded Bitmaps are large but fast to display — a 1080×1920 ARGB_8888 image is 8MB. Keeping them decoded in memory means zero CPU cost on repeated display. Disk cache stores the original compressed JPEG (~100KB) — 80× smaller — so you can cache hundreds of images without filling the disk. The trade-off: disk hits still pay the decode CPU cost, but save the network round-trip.
LRU (Least Recently Used) evicts the item accessed furthest back in time. For image caches it works because temporal locality holds — if you viewed an image recently, you're likely to view it again soon. Android's LruCache uses a LinkedHashMap with accessOrder = true — on every get/put, the accessed entry moves to the tail. Eviction picks the head.
A BitmapPool is a collection of recycled Bitmap objects grouped by (width, height, config). When the memory cache evicts a Bitmap, instead of letting it be GC'd, it's placed in the pool. The next decode that needs a Bitmap of the same size borrows from the pool and sets it as BitmapFactory.Options.inBitmap — the decoder writes new pixels directly into the existing allocation. The benefit: zero heap allocation for the decode, eliminating GC pauses during RecyclerView scrolling.
inSampleSize downsamples an image during decode. A value of 2 halves each dimension (¼ the pixels); 4 gives 1/16 the pixels. Without it, loading a 4K photo into a 100×100 thumbnail wastes 640× the memory. The correct flow: (1) Set inJustDecodeBounds = true, decode to get dimensions. (2) Calculate minimum inSampleSize that produces output ≥ target dimensions. (3) Set inJustDecodeBounds = false and decode at that sample size.
Maintain a ConcurrentHashMap<CacheKey, Deferred<Bitmap>> of in-flight requests. When a second request arrives for the same URL+dimensions, check the map — if a Deferred already exists, call .await() on it instead of starting a new coroutine. All 50 cells share one network request and one decode. Use getOrPut atomically to avoid races. Clean up the entry in a finally block.
Store the coroutine Job as a tag on the target view: view.setTag(R.id.image_loader_tag, job). When a new request starts on the same view, retrieve the old tag and call job.cancel() before launching the new one. In Compose, use LaunchedEffect(url) — it automatically cancels and re-launches whenever the URL key changes. No manual cancellation code needed.
The same URL loaded into a 100×100 thumbnail and a 1080×1080 full-screen view produces two different decoded Bitmaps. If you keyed only by URL, the 1080×1080 Bitmap would be returned for the 100×100 slot — wasting 100× the memory. The correct key is CacheKey(url, width, height, config, transformations). This means the same URL can have multiple entries in the memory cache, each optimised for a specific target size.
Android calls onTrimMemory(level) on the Application when the system is under memory pressure. The image loader must register a ComponentCallbacks2 listener and respond proportionally: TRIM_MEMORY_UI_HIDDEN → clear the memory cache. TRIM_MEMORY_RUNNING_CRITICAL → clear memory cache + BitmapPool. TRIM_MEMORY_COMPLETE → clear everything. Not responding is a common source of OOM kills when the app is backgrounded.
Glide maintains a separate ActiveResources map of WeakReference<Bitmap> for images currently displayed on screen. Problem it solves: if image A is displayed and the LRU cache fills up with other images, image A could be evicted — but it's still being drawn. When the view releases the Bitmap (scrolled off screen), it moves from Active Resources to the LRU cache. This two-layer approach means on-screen images are never evicted.
Design every component behind an interface: ImageCache, DiskCache, Fetcher, Decoder, BitmapPool. In tests, inject fake implementations. For the Fetcher, use MockWebServer. Test the Engine's deduplication with a slow fake Fetcher using a Semaphore — fire 10 concurrent requests and assert only one fetch happens. Test lifecycle cancellation by calling job.cancel() mid-fetch and asserting no view update occurs.
Use an atomic write pattern: write to a temp file (key.tmp), then rename to the final path (key) when complete. File rename is atomic on POSIX systems. This prevents a concurrent reader from seeing a partially written file. DiskLruCache implements this pattern. Additionally, use a per-key Mutex to prevent two coroutines from fetching and writing the same key simultaneously.
Define a Fetcher interface with a type parameter: interface Fetcher<T> { suspend fun fetch(data: T): ByteArray }. Register fetchers per source type in a FetcherRegistry: HttpFetcher for URLs, FileFetcher for file paths, ResourceFetcher for R.drawable IDs, ContentUriFetcher for content:// URIs. This is exactly how Coil's Fetcher SPI works.
Use a RecyclerView.OnScrollListener to detect scroll direction and calculate which items will enter the viewport. Call imageLoader.preload(url, size) for the next 3–5 items — this runs the full pipeline without attaching to any view. Use a lower-priority coroutine dispatcher for preloads so they don't compete with visible-item loads. Cancel preloads when scroll direction reverses.
Key differences: (1) Coroutines-first: Coil is built entirely on Kotlin coroutines; Glide uses Java threads + callbacks. (2) OkHttp integration: Coil uses OkHttp natively and defers to OkHttp's HTTP cache, avoiding a separate disk cache layer. (3) No static singleton by default: Coil encourages injecting a custom ImageLoader instance, making it more testable. (4) Compose-first API. (5) Coil is newer and smaller in APK size (~3MB vs Glide's ~5MB).
Transformations must be included in the CacheKey. A circle-cropped version and the original of the same URL are different Bitmaps and must be stored separately. The key typically includes a hash of the transformation class name and parameters. For disk cache, cache the untransformed bytes (one file serves multiple transformations) but cache the transformed Bitmap in memory — memory hits are free.
Key patterns: (1) Use AsyncImage with a rememberAsyncImagePainter — LaunchedEffect handles cancellation automatically. (2) Set explicit contentScale and size modifiers so the library can downscale to exactly the render size. (3) Use allowHardware = false only if you need to draw on Canvas — hardware Bitmaps are faster for display. (4) Use ImageLoader as a singleton — creating a new one per composable defeats the shared cache.
Two approaches: (1) Size-based eviction: Check the content-length header before writing and skip if it exceeds a threshold (e.g. 10MB) — it would evict everything else. (2) Streaming decode: For very large images, stream decode using BitmapRegionDecoder instead of loading the full bytes into memory. Decode only the visible region — how Google Photos handles extremely large images without OOM.
Three layers: (1) Microbenchmarks — use androidx.benchmark to measure decode(bytes) time, memoryCache.get(key) latency, and LRU eviction throughput. (2) Macro benchmarks — use MacrobenchmarkRule to measure scroll frame rate (jank %) in a RecyclerView. (3) Memory profiling — use Android Profiler to compare heap allocations during a 30-second scroll session. A well-implemented BitmapPool should show near-zero Bitmap allocations after the first pass.
Strong Staff+ answer: (1) Coroutines-native from day one — Glide's Java callback model makes coroutine integration awkward. (2) Delegate HTTP caching to OkHttp — let OkHttp own the byte cache; own only the decoded Bitmap cache. (3) Compose as the primary target — design the Target interface as a composable state holder, not an ImageView wrapper. (4) KMP-ready data layer — the cache key, LRU logic, and decoder interface could be Kotlin Multiplatform.
Two guards are required: (1) Job cancellation: when a cell is rebound, retrieve the old Job from view.tag and cancel it — this stops the in-flight coroutine if it hasn't completed. (2) URL tag guard: tag the view with the expected URL before launching the new request. When the result arrives, check view.tag == expectedUrl before calling setImageBitmap. This handles the race where the old request completes after the new one started. In Compose, LaunchedEffect(url) handles both automatically — a url key change cancels the previous effect.
Wrap BitmapFactory.decodeByteArray in a try/catch for OutOfMemoryError (not Exception — OOM is an Error). On catch, double the inSampleSize and retry — this quarters the pixel count. Retry up to 3 times with progressively larger sample sizes. If all retries fail, call System.gc() as a hint (not guaranteed), then show the error drawable. Also clear the in-memory BitmapPool before the last retry attempt, since pooled Bitmaps are still consuming heap. Never crash the app over a single image load.
A cache stampede occurs when many coroutines simultaneously miss the same disk cache entry and all try to fetch from network + write to disk. The result: N redundant network requests and N concurrent disk writes to the same file — corrupting the entry. Prevention: (1) The in-flight activeRequests map deduplicates ongoing requests. (2) A per-key Mutex around disk reads/writes ensures only one coroutine fetches each key at a time. (3) The double-check pattern (check disk cache again after acquiring the mutex) ensures the second coroutine gets a cache hit rather than redundantly re-fetching.
Use HARDWARE (the default in Coil) when: displaying an image in an ImageView or Compose Image — it lives in GPU memory, zero heap impact, faster compositing. Avoid HARDWARE when: applying Canvas-based transformations (the Bitmap is not mutable), saving to file using getPixels(), passing to native code, or using as inBitmap in BitmapPool (HARDWARE Bitmaps are immutable). The safe pattern: use allowHardware(true) by default, and allowHardware(false) only in the specific requests that need CPU access. Blanket-disabling hardware Bitmaps causes significant memory and performance regression.
The ImageLoader must be a singleton scoped to the Application, not the Activity. Since Application survives rotation, the memory cache and BitmapPool survive too — any loaded Bitmap doesn't need to be re-fetched. In-flight requests are different: they're tied to the old Activity's coroutine scope. On rotation, the old Activity is destroyed, cancelling in-flight jobs. The new Activity's onBindViewHolder / LaunchedEffect starts fresh requests — which get memory cache hits immediately since the Bitmaps were already loaded. The key mistake to avoid: scoping the ImageLoader to a ViewModel's scope loses the cache on Activity removal from the back stack.