Mobile Design Rounds
Build guideAndroid + iOS20 min read

Build it: an image loading library on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design an image loading library (Android and iOS): caches, decoding and cancelling · The framework.

This is the build material behind the image loading interview case. It covers:

  • the API sketch and the pipeline
  • the cache key and the code for coalescing (joining duplicate requests into one shared download)
  • recycling rules, decode and HTTP caching
  • failure modes, platform limits and the Swift version

None of it belongs in the 45 minutes. I draw it only if asked. The Kotlin snippets are sketches that use stubs (empty stand-ins for real classes). I describe the Coil and Glide behavior from their documentation, not their source.

1. Public API

Design the API first, because it forces the lifecycle questions (how long each thing lives). The shape I would use is a request value object plus a target. A value object never changes after it is made. A target is the view or Compose image that shows the picture. Coil's ImageRequest follows the same shape.

The block below is an illustrative API, not Coil's exact names. For example, Coil's rounded-corners transformation takes a pixel radius, not dp:

// 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

interface ImageLoader {
    fun enqueue(request: ImageRequest): Disposable                // views: cancel() on recycle
    suspend fun execute(request: ImageRequest): ImageResult       // non-view callers and tests
    fun prefetch(request: ImageRequest)                           // no target: warm the caches
    fun trimMemory(level: Int)                                    // host forwards system signals
}

Points to say out loud:

  • A request is a value object. It never changes, you can compare it, and it is cheap to build. So a list adapter can create one for each bind.
  • The target owns binding. The loader writes to the target only if the request is still the current request of that target. This fixes the recycled-row bug (section 4).
  • The host owns the lifecycle and the memory budget. The host is the app that uses the library. 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. Use constructor injection (the app passes the loader in) with a Hilt-provided singleton. Call enqueue from the UI layer. prefetch also comes from the UI, or from a data-layer class, never from a ViewModel that holds a Context.

2. Pipeline and architecture

 View / Compose target
    │ enqueue(request)         ▲ result (only if still current)
 ImageLoader  ── Interceptor chain ──────────────────────────────┐
    │                                                            │
 Memory cache (byte-bounded LRU) ──hit──► deliver                │
    │ miss                                                       │
 In-flight map (key → shared Deferred)   ← coalescing            │
    │ first caller only                                          │
 Disk cache (LRU, size-capped) ──hit──► decode                   │
    │ miss                                                       │
 Network (OkHttp / URLSession) → write disk → decode(target size) → transform → memory cache

Official architecture mapping: the loader sits at the UI layer boundary. See UI layer and data layer.

  • Composables and Views call the loader. ViewModels only expose image URLs in uiState. This follows the advice of the UI-layer page to keep UI logic that involves types like Context out of the ViewModel.
  • The network and disk code of the loader are the library's own data sources.
  • The shared downloads run in a scope that I inject. A scope is the place where background tasks run and are canceled together. So one caller that leaves does not cancel the shared downloads.

Stage by stage:

  1. Memory cache. Decoded bitmaps (pictures ready to draw) sit in an LRU. An LRU removes the least recently used entry first. The loader checks it at once on the calling thread, because the lookup is fast. A hit means no frame is lost to background work.
  2. In-flight map. In-flight means already running. Before it starts work for a key, the loader checks whether someone already does that work (section 3).
  3. Disk cache. Encoded bytes (or processed results). The loader checks them off the main thread, which is the thread that draws the screen.
  4. Network. Conditional requests, cache headers and a limit on parallel requests.
  5. Decode. Sample down to the target size, off the main thread.
  6. Transform. Crop, round, blur. A transform is part of the cache key.

Glide's docs describe the same order. It checks active resources (images that another view already shows), 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.

3. Cache keys, coalescing and the memory cache

Cache key design

The key must change whenever the output would change. For memory, the key is (source, target size, transforms, config). Config is the pixel format. Glide's docs say the active-resource, memory and resource-disk keys include the model, width and height, an optional transformation, options and an optional signature. The data-disk key has at least the model and the 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. A rounded version and a plain version are different results.
  • A disk cache can use a looser key. You can store the original encoded bytes under the URL key and decode again for each size. Or you can store each processed result. The first costs CPU for every decode. The second costs disk space. Glide's default for remote images is to store the unmodified original.
  • Use a version in the URL or a signature. Do not guess. If the server replaces an avatar at the same URL, the cache serves old pixels until it evicts them (removes them to make room). You need a content hash in the URL or an explicit signature.

Memory LRU and request coalescing (sketch)

The trim thresholds below are my example values. The LruCache use follows the sizing idea in the Android cache bitmaps guide. The key object, allocationByteCount and the trim logic are mine.

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(external: CoroutineScope) {
    // Same context as the injected scope, but a SupervisorJob: one failed fetch must not cancel the others.
    private val scope = CoroutineScope(external.coroutineContext + SupervisorJob(external.coroutineContext[Job]))
    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 {
                    val me = coroutineContext[Job]
                    try { work() } finally { synchronized(this@Coalescer) { inFlight.remove(key, me) } }   // only our own entry, never a newer one
                }
            }                                                      // 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:

  • Limited by bytes. The Android docs size the cache in bytes by overriding sizeOf. They suggest about one eighth of the available memory of the app as an example. Bitmaps differ greatly in size, so a limit on the number of entries has no meaning.
  • Work is shared, and ownership is counted. One download and one decode serve every waiter. A waiter is a caller that still waits for the result. Canceling one row must not cancel the work for the others. So the work is canceled only when the last waiter leaves.
  • Remove the entry when the work ends. The finally block of the worker drops its own entry when the work finishes, with success or failure. A later caller then starts a new fetch. It does not join a finished one.
  • Remove only your own entry. The finally block removes the entry only if the map still holds this exact job. A late finally from a canceled job cannot delete the entry of a newer request.
  • Failure reaches every waiter. All callers that are still attached when a fetch fails get that failure. The loader caches nothing.
  • One failed fetch must not stop the others. The scope that the host injects is usually a plain Job. A failed async child cancels its parent, and that would cancel every other load. The coalescer wraps the scope in a SupervisorJob, so a 404 fails only the callers that wait for that key.
  • Do not use weak or soft references as a cache. These are references that the garbage collector may clear. The Android docs say they are not recommended, because the garbage collector clears them aggressively.

4. Views, recycling and cancellation

Lists recycle views (they reuse the same views for new rows). A request for row 3 may finish after the app has rebound the view to row 40. The rules:

  1. Bind a request to the target. Setting a new request on a view cancels the old one. Clearing a view (on recycle or detach) cancels the current one.
  2. Check before delivering. When the request finishes, compare its ID with the current request of the target. If they differ, drop the result for the view, but still put it in the cache. The Coalescer sketch leaves that put to the work lambda that is passed to load.
  3. Tie the request to the lifecycle. Start in the started state and cancel when destroyed. In Compose, use the composition scope, so that leaving the composition cancels the request.
  4. Resolve the size later. A view has no size until the layout runs. Wait for the layout. Do not load at full resolution.
  5. Cancel prefetch for rows that the user scrolled past. Give prefetch a lower priority than visible loads.

The feed side of this (what to prefetch and when) is in the news feed case study.

A loader without cancellation downloads and decodes images for rows that no one will see. This costs battery, data and frame time.

5. Decode, downsample and memory pressure

A decoded bitmap costs width x height x bytes per pixel, whatever the file size. The Android docs give an example. A 2048x1536 image at ARGB_8888 uses about 12 MB, and about 0.75 MB at inSampleSize = 4. So:

  • Downsample to the target size. Read the dimensions first (inJustDecodeBounds). Pick a sample size (the shrink factor). Then decode. inSampleSize rounds to a power of two, so you land at or above the target size. Scale the last step if the exact size matters. On newer API levels, ImageDecoder with a target size does the same.
  • Ask the server for the right size too. A CDN (content delivery network) resize parameter saves bytes on the network as well as in memory. Put it in the key.
  • Decode off the main thread, on a dispatcher with a limit (a limited pool of threads). Cap the number of parallel decodes, for example at the CPU count. Then a fast scroll cannot cause a spike in memory with parallel full decodes.
  • Choose the config on purpose. RGB_565 halves memory for opaque photos but lowers quality. HARDWARE bitmaps live in graphics memory, but you cannot read them back or change them.
  • Bitmap pooling. Reusing bitmap memory (inBitmap) reduces new allocations and GC (garbage collection) pauses during fast scrolls. Glide has a BitmapPool that responds to ComponentCallbacks2. Pooling adds optional complexity. Mention it, and name the trade-off: reuse needs matching sizes and careful ownership.
  • React to memory pressure. The system calls onTrimMemory to ask the app to free memory, and the host forwards it. Evict everything on TRIM_MEMORY_BACKGROUND (40). Shrink the cache on TRIM_MEMORY_UI_HIDDEN (20).
  • Skip the deprecated levels. The RUNNING_*, MODERATE and COMPLETE levels are deprecated (marked as outdated). On API 34 and later, apps are not notified of them. So handle them only if you support older releases (checked in the ComponentCallbacks2 source).
  • Glide trims itself. Glide registers itself as a ComponentCallbacks2. It trims its memory cache and bitmap pool from onTrimMemory (I read this in the Glide source, not its docs).
  • Expect a limit that depends on RAM on Android 17. Android 17 introduces app memory limits based on total device RAM. A session that the system ends this way reports REASON_OTHER in ApplicationExitInfo, with a description that contains MemoryLimiter:AnonSwap (see the Android 17 behavior changes).
  • My view: log that exit reason next to your OOM (out-of-memory) metric. Also set the cache budget from the memory class of the device (the memory size that the system suggests for one app), not from a fixed number. The second part is my suggestion, not documented advice.

6. Network, disk cache and invalidation

Disk cache. An LRU over files, with a hard size cap (a fixed number of bytes or a percent of free space). Evict the oldest-used entry first. Write to a temp file, and rename it when the write succeeds. Then a killed process never leaves a corrupt entry. All disk work runs off the main thread. The Android docs say that disk operations should never run on the UI thread.

HTTP rules. Do not invent your own expiry when the server already speaks HTTP:

  • Follow Cache-Control: max-age and Expires to decide whether to skip the network.
  • When an entry is stale, send a conditional request with If-None-Match (from ETag, a version tag that the server sends) or If-Modified-Since. A 304 Not Modified reply reuses the bytes and costs one small round trip (one request and one reply).
  • Coil 3 does not respect Cache-Control by default and always saves responses to its disk cache. You opt in with the coil-network-cache-control artifact (a package). The network code itself comes from a separate artifact such as coil-network-okhttp (Coil network docs). So this is a policy decision, not a default that everyone wants.
  • Signed or authenticated URLs break the key rule. If the URL carries an expiring token in its query string, the same image gets a new key on every refresh.
  • Make the key from the stable part of the URL plus a version. Keep the token out of the key.
  • Never share cache entries across users. Private images need a separate cache space for each user, and logout must clear them (my design advice, not a library guarantee).
  • If the CDN serves content-hashed URLs with a long max-age, the URL change is the invalidation. The client should add nothing more.

Explicit invalidation. Offer invalidate(key) and clear() for logout and "profile photo changed" flows. Prefer a signature or version in the key over deleting files.

Network policy. Limit parallel requests for each host. Retry with backoff (wait longer after each failure) for temporary errors only. Do not retry a 404. Skip prefetch on cellular data or in data saver mode. Use priorities so that visible loads beat prefetch.

Threads in one line: the main thread does memory lookups and delivery. An IO dispatcher (a pool of threads for disk and network work) does the disk and network work. A limited compute dispatcher does decode and transform.

7. Placeholders, animated formats, errors and metrics

  • Placeholder, then image. Show the placeholder at once on bind. A memory hit should skip the placeholder and the fade.
  • Crossfade only for async loads. A fade-in for cache hits looks like flicker in a scrolling list. Coil offers this as crossfade(true) on the request.
  • Progressive loading. Show a low-resolution or blurred preview while the full image downloads. Use a thumbnail URL or a BlurHash-style placeholder. Progressive JPEGs let the decoder draw 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. Pause when the image is off screen. In Coil, these decoders are in a separate optional artifact (coil-gif). This fits good library design: keep heavy features out of the core.
  • Errors. Return a typed result: success with source MEMORY, DISK or NETWORK, or an error with its cause. Show the error image and allow a tap to retry. Never crash on a corrupt file. Keep a negative entry (a saved note that the load failed) for a short time, so the loader does not hit a 404 again and again.
  • Metrics. Track the memory and disk hit rate (how often the cache has the image). Track time to first pixel (how long until the first picture appears). Also track bytes downloaded, decode time, cancellation rate and OOM count. Give the loader an event listener, so the host app can forward them.
  • Testability. Put the loader behind an interface. Inject the dispatchers and a clock. Put the network behind a fake fetcher. Test coalescing with two concurrent requests and one fetch. Test cancellation with a suspended fetcher. Test eviction with a tiny cache.

8. Failure modes

ScenarioHandling
Row recycled before the load finishesCancel on rebind; compare the request ID before delivering
20 rows request the same avatarOne shared download; the waiters share the result
One of those rows scrolls awayCount waiters; cancel the download only when the last waiter leaves
One fetch fails (a 404)SupervisorJob in the scope of the coalescer; only the waiters of that key see the error, and other loads continue
Huge image (for example, 8000x6000)Read the bounds first and sample down; reject or cap images above a pixel limit
Memory pressureTrim on TRIM_MEMORY_BACKGROUND and UI_HIDDEN; limit the cache by bytes; cap parallel decodes
Same URL, new contentSignature or versioned URL; otherwise ETag revalidation
Process killed mid-downloadTemp file plus atomic rename (the rename happens in one step); no partial entries
Corrupt or unsupported fileTyped error, error drawable, short-lived negative entry
Disk fullEvict down to the cap; if writes still fail, serve from the network without caching

9. How to build an image loader on iOS (Swift)

The design is the same: cache by (source, size, transforms), coalesce running requests, cancel on reuse, and downsample on decode. Only the tools change.

ConcernAndroidiOS
Memory cacheLruCache (byte-sized)NSCache with totalCostLimit
Disk / HTTP cacheOwn LRU or OkHttp cacheURLCache, or your own file cache
DownsampleinSampleSize / ImageDecoderImageIO CGImageSourceCreateThumbnailAtIndex
Memory signalonTrimMemorydidReceiveMemoryWarningNotification
CancellationDisposable / coroutine JobTask cancellation, prepareForReuse
PoolBitmapPool / inBitmapNone; rely on ImageIO and system decoding
LibrariesCoil, GlideNuke, Kingfisher

What is really different:

  • Downsample with ImageIO (Apple's image decoding framework), not UIImage. CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize makes a reduced image without first building the full-size bitmap. Without a max pixel size, the thumbnail can be as big as the image. Use the size in pixels, not points: multiply by the screen scale.
  • Set the thumbnail flags. Set kCGImageSourceCreateThumbnailFromImageAlways to true. Add kCGImageSourceShouldCacheImmediately, so the decode happens on your background queue and not at the first draw. Apple documents that flag for other calls, so this part is common practice, not a promise. Add kCGImageSourceCreateThumbnailWithTransform to apply the EXIF (photo metadata) orientation.
  • Memory signals are coarser. UIKit posts didReceiveMemoryWarningNotification and calls didReceiveMemoryWarning(). Apple's page for the notification says it has no userInfo dictionary, so it carries no level. I treat it as one signal: drop most of the cache on the warning and when the app enters the background. A dispatch memory-pressure source can free memory earlier. I have not checked its iOS availability, so verify it before you rely on it.
  • NSCache evicts for you, but Apple says the cost limit is not strict. An object may be evicted at once, later or never. The default limit is 0 (no limit), so set one, and still clear on a memory warning.
  • URLCache gives you HTTP caching (including conditional requests) with a memory and disk capacity that you set. Default sessions use the shared cache. Background sessions default to nil. So a simple iOS loader can rely on it for the encoded bytes, and cache only the decoded images itself.
  • Reuse. In UICollectionView cells, cancel in prepareForReuse and check the request token before assigning.
// Sketch: memory cache + coalescing in one actor. Not production code.
// Swift 6 needs an SDK where UIImage is Sendable; otherwise wrap it in a Sendable box.
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: Set<UUID>] = [:]       // one id per caller, so leaving twice is harmless

    func image(for key: CacheKey) async throws -> UIImage {
        if let hit = memory.object(forKey: key.nsKey) { return hit }
        let me = UUID()
        let task = inFlight[key] ?? Task.detached { try await Self.fetchAndDownsample(key) }
        inFlight[key] = task
        waiters[key, default: []].insert(me)
        defer { leave(key, me) }                           // normal return or error
        let image = try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            Task { await self.leave(key, me) }             // caller cancelled: release now, not when the fetch ends
        }
        memory.setObject(image, forKey: key.nsKey, cost: image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0)
        return image
    }

    private func leave(_ key: CacheKey, _ id: UUID) {
        guard waiters[key]?.remove(id) != nil else { return }
        if waiters[key]?.isEmpty == true {                 // last waiter left: cancel the shared fetch
            waiters[key] = nil
            inFlight.removeValue(forKey: key)?.cancel()
        }
    }
}

The actor replaces the lock from the Kotlin version. Its isolation (only one call runs inside it at a time) serializes access to inFlight and waiters. Actors are reentrant (other calls can run while one call waits). So the code never assumes that inFlight or waiters stay the same across the await.

Awaiting task.value alone would not release a canceled caller until the fetch ended. So the await sits inside withTaskCancellationHandler. It removes the id of that caller at once. It cancels the fetch only when the last id is gone.

The sketch keys the size as one pixelSize, where the Kotlin key has width and height.

10. Platform limits that change the design

Every cell repeats a fact from this page. The last column says what it changes in the design.

LimitAndroidiOSSo the design changes by
Memory signalonTrimMemory; only TRIM_MEMORY_BACKGROUND and TRIM_MEMORY_UI_HIDDEN are not deprecated, and apps are not notified of the other levels on API 34 and laterOne didReceiveMemoryWarningNotification; it has no userInfo, so no levelAndroid can shrink in steps; iOS drops most of the cache on the warning and on entering the background
Cache sizeLruCache sized in bytes through sizeOfNSCache cost limit defaults to 0 (none) and is not strictOn iOS set a cost limit and still clear on warning
HTTP and disk cacheYour own LRU, or the OkHttp cacheURLCache; default sessions share one, background sessions default to nilA simple iOS loader can rely on URLCache for encoded bytes
Per-app memory ceilingAndroid 17 limits scale with total device RAM; an affected exit reports MemoryLimiter:AnonSwapNot checkedOn Android, log that exit reason with the out-of-memory metric; the memory-class budget is my suggestion

11. More practice prompts

  1. The server changes an image at the same URL. What happens? (Signature or versioned URL; otherwise ETag revalidation; explicit invalidate.)
  2. How do you test and measure it? (Fake fetcher and dispatchers; hit rate, time to first pixel, bytes, cancel rate, OOM rate.)
  3. A product manager wants blur-up placeholders for every image. What does it cost? (A second small request or an embedded hash for each image, extra decode work, and a cache entry for each placeholder.)

These are practice prompts. I do not claim that any company asks them.

The feed that drives most of this traffic is in the news feed case study, and the paging side is in the pagination library case study. Video thumbnails and autoplay follow the same rule of canceling work that is off screen, see the video app case study.

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this guide is the 'Build it' part of the image loading case, moved to its own page on 2026-10-14 so the interview page stays inside 45 minutes. Its code blocks (the API sketch, the pipeline drawing, CacheKey, MemoryCache, Coalescer and the Swift ImagePipeline) are carried over byte for byte from the audited page; only the section numbers (1 to 11), the Failure modes heading (promoted from an H3 to its own section), the iOS heading, this intro, the platform limits table and the cross-references changed. The platform limits table adds no new fact; each cell restates a claim verified below. 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. Changed after the audit pass (2026-10-13, fetched that day): the onTrimMemory levels and values are now verified from the AOSP ComponentCallbacks2 source (only TRIM_MEMORY_BACKGROUND and UI_HIDDEN are not deprecated; the source says apps are not notified of the other five since API 34); the Android 17 memory-limit text and MemoryLimiter:AnonSwap exit description are from the Android 17 behavior-changes page; the Coil 3 Cache-Control default and artifact names are from the Coil network page; the Coil README feature line (memory and disk caching, downsampling, automatic pausing/cancelling), the Nuke feature list (memory and disk cache, request coalescing) and the Kingfisher feature list (hybrid cache, cancelable downloads) are from the READMEs, which name those features without explaining them. The Coalescer removes its entry in a finally (only if the map still holds that job) and runs its work in a SupervisorJob child scope; both fixes are my own. Still not verified against a fetched primary source: Coil crossfade details beyond the builder call, Coil/Glide in-flight coalescing (Glide's was never confirmed), bitmap pooling, the RGB_565 and HARDWARE trade-offs, ImageDecoder target sizing, inBitmap constraints, getMemoryClass() as a cache budget, the iOS availability of a dispatch memory-pressure source, and the signed-URL advice (design opinion). Apple items verified earlier via the Apple docs JSON: ImageIO option defaults (ShouldCacheImmediately is documented only for other calls, so its effect on thumbnails rests on common practice), NSCache cost-limit wording, URLCache and session-configuration defaults; I removed the claim that NSCache eviction order is unspecified because I found no Apple page for it. No WWDC session is cited. The Swift actor (waiter ids plus withTaskCancellationHandler) was rewritten in this pass; the Sendable comment above it is a caveat, not a check. No iOS engineer has reviewed the iOS sections yet. The interview expectations, level split and 45-minute plan are the author's synthesis; no Design Gurus or ProAndroidDev page was read. The staff-answer section on the interview page is verified against the Coil, Glide, Nuke and Kingfisher READMEs and the Google Play staged-rollout help page; the migration plan and metrics are opinion. Code checks run on 2026-10-13, offline, nothing downloaded. Compiled: the ImageRequest and ImageLoader snippet and the CacheKey/MemoryCache/Coalescer snippet, with Gradle 9.8.1 --offline, AGP 9.1.0, Kotlin 2.3.21, compileSdk 36, minSdk 26, coroutines 1.10.2 (compileDebugKotlin clean); the ImageRequest.Builder, RoundedCorners, ViewSizeResolver, Disposable, ImageResult and R types are stubs, so the first snippet only shows the call shape. Run: the Coalescer logic in a small JVM probe (coroutines 1.10.2, with the Bitmap type swapped for a String), which showed a failed fetch cancelling an unrelated load without the SupervisorJob and not with it, three concurrent callers sharing one fetch, and a fresh fetch after the last waiter cancelled. Type-checked, not run: the Swift actor, with Swift 6.4 against the iOS 27.0 simulator SDK (-target arm64-apple-ios17.0-simulator), language modes 5 and 6, with the fetch function stubbed; its cancellation behaviour was not exercised at runtime. Not checked: MemoryCache was compiled but its trim was not exercised at runtime, and no Android lint was run. Re-checked on 2026-10-14 against the official pages: the load-bitmap page (2048x1536 at ARGB_8888 is 12MB, 0.75MB at a sample size of 4, and the decoder rounds the sample size down to a power of two), the cache-bitmap page (cache measured in bytes through sizeOf, one eighth of app memory as an example, weak and soft references not recommended), the AOSP ComponentCallbacks2 source (TRIM_MEMORY_BACKGROUND 40 and TRIM_MEMORY_UI_HIDDEN 20 current, the rest deprecated and not delivered since API 34), the Android 17 behavior-changes page (memory limits based on total RAM, REASON_OTHER with MemoryLimiter:AnonSwap, no limit values given) and the UI-layer page. The code is unchanged and was not recompiled in this pass. Second review pass, 2026-10-14, fetched that day: the code blocks were diffed against the audited page and are byte-identical; the pages cited above returned 200 and every internal link target exists. Changed prose only. The architecture mapping no longer says a ViewModel never holds a Context: the UI-layer page says to keep UI logic involving UI types like Context out of the ViewModel because the ViewModel has a longer lifespan, and does not state a ban. The Glide key sentence now separates the resource-disk key (model, signature, size, transformation, options, data type) from the data-disk key (at least the model and the signature). The trim-level wording now says not deprecated, because the AOSP source marks five levels deprecated with 'Apps are not notified of this level since API level 34' and says nothing explicit about delivery of the other two. The iOS memory notification is now stated as carrying no level, because Apple's page for didReceiveMemoryWarningNotification says it has no userInfo dictionary (the Apple docs JSON, read that day). The claim that Glide trims its memory cache and bitmap pool on onTrimMemory was read in the Glide source on GitHub (the Glide class implements ComponentCallbacks2 and calls trimMemory on both), not in its documentation. The sentence on CDNs with hashed URLs no longer says 'many CDNs'. I added one sentence saying the Coalescer sketch leaves the cache put to the work lambda, because the page says a late result still goes into the cache and the sketch does not show that line. Coil and Glide pages were searched for in-flight request coalescing and none mentions it. Still not verified: the ones listed above, and the dispatch memory-pressure source on iOS. A readability pass on 2026-10-14 split long sentences and paragraphs and turned runs of parallel items into lists without changing any fact, number, link, code block or heading. A plain English pass on 2026-10-14 (spec section 9) rewrote the prose in short sentences, defined each technical term where it first appears and used one word for each idea (shared download, waiter, target, negative entry, trim signal, main thread), again without changing any fact, number, link, code block or heading.

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.