Design an image-loading library from scratch (think: Glide/Coil/SDWebImage)
Sketch the request pipeline — memory cache, disk cache, network, decode, and lifecycle-aware cancellation — that every image loader in production actually runs.
LEARN BY DOINGPut your intuition to work3 steps
Design an image-loading library from scratch (think: Glide/Coil/SDWebImage)
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
An image-loading library sounds like a solved problem — "fetch a URL, show a bitmap" — but the reason Glide, Coil and SDWebImage each run to tens of thousands of lines is that the real requirements are all about scarce resources under churn: a RecyclerView/LazyColumn can bind and unbind fifty image requests a second while scrolling, memory is tight enough that a handful of full-resolution bitmaps can OOM the app, and every request needs to be cancelled the instant its view disappears or it's just wasted CPU and bandwidth. So I'd frame the requirements as: (1) never block the main thread — not for cache lookups, not for decoding, not for anything; (2) never hold a bitmap larger than what the target ImageView actually needs; (3) automatically cancel work tied to a destroyed view/lifecycle; (4) cache aggressively across two tiers (memory and disk) so re-scrolling or reopening a screen is instant; (5) transform images (resize, crop, blur) as a composable pipeline step, not a one-off. I'd also confirm whether this needs GIF/WebP animation support and placeholder/error/crossfade UX, since those shape the public API even if they don't change the core pipeline.
#The request pipeline
The mental model I'd design around is a single request flowing through an ordered chain of resolvers, each of which can either satisfy the request immediately or hand it to the next stage: memory cache → disk cache → network fetch → decode → transform → memory cache write → target. The key design insight is that every stage after "check memory cache" happens off the main thread, and the pipeline is keyed not just by URL but by a composite key that includes requested size and any transformations, because a 1200px source image resized to a 48px thumbnail and to a 400px hero image are different cache entries — caching only by URL is the single most common beginner mistake in this space, and it silently defeats the entire point of downsampling.
#Caching: two tiers, two very different policies
The memory cache is an LRU keyed by the composite request key, sized as a fraction of available app memory (a common heuristic is roughly an eighth of the max heap, tuned per device class), storing decoded, ready-to-draw bitmaps. Eviction has to be size-aware, not count-aware — a cache holding twenty 4K wallpaper bitmaps and a cache holding twenty 48px thumbnails are wildly different memory footprints, so the LRU's "size" function returns byte count, not entry count. The disk cache stores the original, undecoded bytes (not the resized bitmap) keyed by URL alone, because the raw network response is reusable across every size/transform variant a caller might request later — re-decoding from a cached file is still far cheaper than re-fetching from the network. I'd size the disk cache as a capped LRU on disk (tens to a couple hundred MB depending on the app) with the classic DiskLruCache-style journal for crash-safe eviction bookkeeping.
#Decoding is the part that actually causes crashes
Naively calling BitmapFactory.decodeStream (Android) or UIImage(data:) (iOS) on a raw 12MP JPEG intended for a 64dp avatar allocates a bitmap dozens of times larger than the screen can even display, and doing this for every cell of a scrolling list is the classic cause of OutOfMemoryError/memory-pressure jetsam kills. The pipeline has to inspect the image's bounds first (inJustDecodeBounds on Android, or reading the CGImage source properties on iOS) without allocating pixel data, compute a downsample factor from the target view's measured size, and only then decode at that reduced resolution (inSampleSize, or CGImageSourceCreateThumbnailAtIndex with a kCGImageSourceThumbnailMaxPixelSize). This single step — decode-at-target-size instead of decode-then-scale — is usually the biggest performance and memory win in the whole system, bigger than either cache tier.
#Lifecycle-aware cancellation and request dedup
Two things distinguish a toy loader from a production one: cancellation and dedup. Every request is tagged to the target view and, ideally, to an observer of the caller's lifecycle (an Activity/Fragment/Compose LocalLifecycleOwner, or a UIViewController's deinit via a weak reference); when that lifecycle reaches destroy, or when the view is about to be reused for a different URL (the classic RecyclerView "recycled cell shows the wrong image" bug), the in-flight request is cancelled and its work discarded before it can bind a stale bitmap. Dedup means that if the same key is requested twice concurrently — two cells briefly showing the same avatar during a fast scroll — the pipeline attaches both callbacks to a single in-flight Job/Task instead of doing the fetch and decode twice.
// A trimmed public API sketch — enough surface to discuss, not a full implementation.interface ImageLoader {fun load(url: String): RequestBuilder}interface RequestBuilder {fun size(width: Int, height: Int): RequestBuilderfun placeholder(@DrawableRes res: Int): RequestBuilderfun error(@DrawableRes res: Int): RequestBuilderfun transform(vararg transformations: Transformation): RequestBuilderfun crossfade(durationMs: Int = 200): RequestBuilderfun into(target: ImageView): Disposablesuspend fun await(): Bitmap}interface Transformation {fun key(): String // participates in the memory-cache keyfun transform(input: Bitmap): Bitmap}interface Disposable {fun dispose() // cancels in-flight work, detaches from target}// Internal shape: a request is deduped and cancellable by construction.internal class ImageRequest(val key: String, // url + size + transform signatureval target: WeakReference<ImageView>,val lifecycle: Lifecycle?,val job: Deferred<Bitmap>, // shared across dedup'd callers)
#Network layer and prioritization
The network stage sits behind an interface (so it can be backed by OkHttp, URLSession, or whatever the host app already uses) and supports request coalescing — the same URL requested by five different views results in one HTTP call. I'd also add priority: images currently visible on screen should preempt images prefetched for content the user hasn't scrolled to yet, which means the underlying executor is a priority queue, not a plain thread pool, and visible-vs-prefetch requests carry different priorities that a scroll listener adjusts as items enter/exit the viewport.
#Tradeoffs I'd call out
The biggest tradeoff is memory-cache size versus decode cost: a bigger memory cache means fewer redundant decodes but more pressure toward OOM on low-end devices, so I'd make the cache size adaptive to the device's memory class rather than a fixed constant. Another is disk-cache eviction policy — pure LRU is simple but doesn't account for image size variance, so a large hero image can evict a dozen small thumbnails; a size-weighted LRU is more correct but adds bookkeeping cost. Finally, I'd note that this whole design assumes bitmap-based raster images; supporting vector formats or video thumbnails would need additional decoder strategies but would slot into the same pipeline shape at the decode stage, which is a good sign the architecture is decomposed at the right seams.