🏗️ System Design Hard 2025–26

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.

Understanding the Problem

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.

Functional Requirements
Core Requirements
  1. Load an image from a URL and display it in an ImageView or Compose AsyncImage
  2. Cache images at two levels — memory (fast) and disk (persistent across launches)
  3. Support placeholder images while loading and error images on failure
Below the line (out of scope):
  1. Video / GIF support
  2. Image transformations (blur, circle crop) — mention as extensible, not core
  3. SVG / vector format support
  4. Cross-fade animations between placeholder and result
Non-Functional Requirements
Core Requirements
  1. Zero main thread blocking — all I/O and decode must happen on background threads
  2. Memory efficient — no OOM crashes; Bitmap memory must stay bounded
  3. Lifecycle aware — cancel in-flight requests when the view or Activity is destroyed
  4. No duplicate network requests — if two views request the same URL simultaneously, fetch only once
Below the line (out of scope):
  1. P99 latency guarantees (cache hit should be <1ms, but we won't guarantee it contractually)
  2. 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.

The Set Up
The 3-Tier Cache Hierarchy

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.

Tier 1
Memory Cache
Speed: <1ms
Size: ~15% of RAM
Eviction: LRU
Stores: decoded Bitmap
Tier 2
💾
Disk Cache
Speed: ~5–50ms
Size: ~250MB
Eviction: LRU
Stores: compressed bytes
Tier 3
🌐
Network
Speed: 200ms–3s
Size: Unlimited
Eviction: N/A
Stores: origin server
🔷Why store decoded Bitmaps in memory but compressed bytes on disk?

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.

Core Components

Before drawing the architecture, align on the six components you'll be designing:

  1. ImageLoader — the public API entry point. Accepts a request and orchestrates the pipeline.
  2. MemoryCache — an LRU map of CacheKey → Bitmap. Size-bounded by system RAM.
  3. DiskCache — an LRU file store (using DiskLruCache). Stores compressed image bytes keyed by URL hash.
  4. Fetcher — downloads bytes from the network using OkHttp. Handles HTTP caching headers.
  5. Decoder — converts raw bytes into a Bitmap using BitmapFactory. Handles downsampling to target size.
  6. BitmapPool — a pool of reusable Bitmap objects to reduce GC pressure. Glide's killer feature.
High-Level Design
1) The Request Pipeline

Every load(url) call flows through this pipeline. The key rule: check cheapest source first, fall through to expensive sources only on a miss.

Caller imageLoader.load(url) ImageLoader Engine + Lifecycle check() Memory Cache LRU · ~15% RAM HIT → Bitmap MISS Disk Cache DiskLruCache · ~250MB HIT→bytes Decoder BitmapFactory + pool MISS Fetcher OkHttp · network call write bytes raw bytes write Bitmap Target ImageView or Compose BitmapPool Recycle · reuse borrow/return Pipeline flow Cache HIT → display Cache MISS → fallthrough
Image Loading Pipeline — check cheapest tier first 🔍
2) Memory Cache — LRU with Size Bounds

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.

3) BitmapPool — Eliminating GC Pressure

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)
}
🔷Pattern: inSampleSize Downsampling

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.

4) Request Deduplication

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()
    }
}
5) Lifecycle Awareness — Cancelling Requests

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(), ...) }
}
Low-Level Design

Three precise flows that interviewers drill into. Be able to draw and explain each one.

Whiteboard Overview

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.

Caller ImageView load(url) Engine dedup + dispatch Dispatchers.IO Memory Cache LRU · decoded Bitmap HIT → Bitmap direct MISS→check Disk Cache DiskLruCache · bytes HIT→bytes Decoder BitmapFactory + pool MISS Fetcher OkHttp network call Target ImageView BitmapPool borrow / return Pipeline Cache HIT Cache MISS
Full Whiteboard — Memory Hit / Disk Hit / Network Miss paths 🔍
Flow 1 — Full Image Load (Network Miss)

The worst-case path: nothing in any cache. This covers every component in the system.

Caller
Engine
Mem Cache
Disk Cache
Fetcher
Decoder
1
imageLoader.load(url, targetView)
Caller invokes the public API. Engine builds a 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.
2
memoryCache.get(key) → null
Memory cache miss. Key was not found — either first load or was evicted. Time elapsed: <1ms.
3
diskCache.get(urlHash) → null
Disk cache miss. Check if a file exists for the URL hash. No file found. Time elapsed: ~5ms.
4
okHttpClient.get(url) → ResponseBody
Network fetch on Dispatchers.IO. OkHttp respects HTTP caching headers (ETag, Cache-Control). Response body streamed into the disk cache write stream.
5
diskCache.put(urlHash, bytes)
Bytes written to disk cache atomically (write to temp file, rename on completion). Future requests get a disk hit without re-fetching.
6
decoder.decode(bytes, targetW, targetH)
Two-pass decode: pass 1 reads only the image header (inJustDecodeBounds) to get dimensions. Calculates inSampleSize. Borrows reusable Bitmap from BitmapPool. Pass 2 decodes into that Bitmap.
7
Bitmap returned → memoryCache.put(key, bitmap)
Decoder returns decoded Bitmap. Engine writes it to memory cache for future hits.
8
withContext(Main) { view.setImageBitmap(bitmap) }
Switch to main thread. Check that the target view's tag still matches this request — view may have been recycled. If it matches, set the Bitmap. If not, drop silently.
Flow 2 — Memory Cache Hit (Best Case)

The happy path — the same image was loaded recently and the Bitmap is still in memory.

Caller
Engine
Mem Cache
Engine
Caller (View)
1
imageLoader.load(url, targetView)
Same call as before. Engine builds the same CacheKey.
2
memoryCache.get(key) → Bitmap ✓
Cache HIT. Returns the decoded Bitmap immediately. No disk I/O, no network, no decode. Time: <1ms.
3
withContext(Main) { view.setImageBitmap(bitmap) }
Bitmap set on the view. From a RecyclerView scroll perspective, the image appears before the next frame — no flicker, no placeholder flash.
Flow 3 — LRU Eviction & BitmapPool Recycling

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.

Engine
Mem Cache (full)
BitmapPool
Decoder
Mem Cache
1
memoryCache.put(newKey, newBitmap)
Engine tries to write a newly decoded Bitmap into the memory cache. Cache is at capacity (e.g. 32MB).
2
LruCache evicts leastRecentlyUsed → Bitmap(800×600)
LruCache.entryRemoved() callback fires with the evicted Bitmap. Instead of calling bitmap.recycle(), pass it to BitmapPool.
3
bitmapPool.put(evictedBitmap)
BitmapPool stores the Bitmap keyed by (width, height, config). It's now available for the next decode of an image with matching dimensions. Zero GC pressure.
4
bitmapPool.get(800, 600, ARGB_8888) → Bitmap
Next decode requesting an 800×600 Bitmap gets the pooled instance. BitmapFactory.Options.inBitmap is set — the decoder writes directly into the existing allocation.
5
memoryCache.put(nextKey, reusedBitmap)
The reused Bitmap, now containing the new image's pixels, is stored in memory cache. The cycle continues with zero new heap allocations for the decode step.
🔷Pattern: Separate Active vs Cached References

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.

Potential Deep Dives
1) How would you support animated GIFs?

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.

2) How do you add image transformations (circle crop, blur)?

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.

3) How do placeholders and error images work?

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.

4) How do you handle HTTP caching headers?

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.

5) How would you implement preloading for a RecyclerView?

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.

Edge Case: OOM & onTrimMemory

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.

Memory Pressure Levels & Response Strategy
LevelMeaningAction
UI_HIDDEN (20)App backgrounded, UI not visibleClear entire memory cache (views can't see images anyway)
RUNNING_MODERATE (5)Low memory, still foregroundTrim memory cache to 50%
RUNNING_LOW (10)Memory is low, GCs imminentTrim memory cache to 25%, clear BitmapPool
RUNNING_CRITICAL (15)App about to be killedClear memory cache + BitmapPool entirely
COMPLETE (80)Process in background LRU, no memory leftClear 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.

OOM During Decode — Defensive Decoding

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
}
Edge Case: View Recycling Race Condition

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.

The Stale Bitmap Problem
RecyclerView
Engine
Network
Engine
ImageView
1
onBindViewHolder(item=A) → view.load(urlA)
Cell is bound to item A. Engine launches a coroutine to fetch image A. Job stored in view.tag.
2
okHttp.get(urlA) — slow network...
Network is slow. Image A takes 800ms. Meanwhile, the user scrolls rapidly.
3
onBindViewHolder(item=B) → view.load(urlB)
The same ViewHolder is rebound to item B. Step 1: retrieve old job from view.tag and call job.cancel(). Step 2: set view.tag = urlB (the new expected URL). Launch new coroutine for urlB.
4
urlA response arrives... tag check!
If cancellation was cooperative, the urlA coroutine was already cancelled at step 3. But if it was mid-IO when cancelled, the response may still arrive. Tag guard: before calling setImageBitmap, check view.tag == urlA. It's now urlB → drop silently. ✓
5
urlB arrives → tag matches → setImageBitmap(bitmapB)
Image B loaded correctly. The cell shows B's image as expected.
// 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))
    }
}
Edge Case: Cache Stampede (Thundering Herd)

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"?

Mutex-Per-Key Pattern

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.

ApproachHandles in-flight duplicationHandles disk raceComplexity
Nothing✗ N requests✗ Corrupt writesSimple
activeRequests map only✗ Race on first requestLow
activeRequests + mutex per keyMedium
Coil / Glide engine✓ (built-in)✓ (DiskLruCache)Done for you
Edge Case: Hardware Bitmaps

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.

Benefits & Restrictions
AspectARGB_8888 (software)HARDWARE
Pixel memory locationJVM heapGPU 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 pressureHigh (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
}
🔷Rule of Thumb: HARDWARE for display, ARGB_8888 for transforms

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.

What is Expected at Each Level
Mid-level
  • 2-tier cache (memory + disk) explained
  • LRU eviction policy understood
  • No main-thread blocking
  • Placeholder + error image support
  • Can describe inSampleSize downsampling
Senior
  • 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
Staff+
  • 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
25 Must-Know Interview Questions

The most frequently asked follow-ups in real Image Loading Library design interviews. Know each one cold.

Q1What are the three tiers of cache in an image loading library and what does each store?
Easy

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.

Q2Why do we store decoded Bitmaps in memory cache but compressed bytes in disk cache?
Easy

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.

Q3What is LRU eviction and why is it the right policy for image caches?
Easy

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.

Q4What is a BitmapPool and why does Glide use it?
Medium

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.

Q5What is inSampleSize and why is it critical for memory efficiency?
Medium

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.

Q6How do you handle request deduplication when 50 RecyclerView cells load the same avatar URL simultaneously?
Medium

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.

Q7How do you cancel an image request when a RecyclerView cell is recycled?
Medium

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.

Q8Why does the CacheKey include target dimensions, not just the URL?
Medium

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.

Q9What happens when the app receives an onTrimMemory() callback?
Medium

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.

Q10What is the "Active Resources" pattern in Glide? Why is it separate from the LRU cache?
Hard

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.

Q11How would you make the library testable?
Hard

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.

Q12How does the disk cache handle concurrent writes to the same key?
Medium

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.

Q13How do you support loading images from sources other than URLs (file path, resource ID, content URI)?
Medium

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.

Q14How would you implement prefetching for a RecyclerView scrolling at high speed?
Hard

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.

Q15How does Coil differ from Glide architecturally?
Hard

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).

Q16How do image transformations (circle crop, rounded corners) affect caching?
Medium

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.

Q17How would you handle image loading in a Compose LazyColumn with 10,000 items?
Hard

Key patterns: (1) Use AsyncImage with a rememberAsyncImagePainterLaunchedEffect 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.

Q18How do you handle images that are too large to fit in the disk cache?
Medium

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.

Q19How would you measure and benchmark the library's performance?
Hard

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.

Q20If you were starting this library today, what would you design differently than Glide?
Hard

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.

Q21A user scrolls a RecyclerView very fast. How do you ensure the correct image always appears in each cell?
Medium

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.

Q22How would you handle an OOM during Bitmap decode? What's the recovery strategy?
Hard

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.

Q23What is a cache stampede in an image loader and how do you prevent it?
Hard

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.

Q24When should you use HARDWARE Bitmap config and when should you avoid it?
Hard

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.

Q25How does your image loader survive an Activity rotation (configuration change)?
Medium

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.