Image Loading (Coil & Glide) Explained
DATA › Networking
Image loading is one of the most reliable Android interview topics because nearly every app fetches images off the network, and there are three classic ways to get it wrong: block the main thread while decoding, decode a photo at full resolution into a tiny thumbnail, or forget to cancel a request when its view scrolls off screen and gets reused. Two libraries dominate how teams solve this. Coil, short for Coroutine Image Loader, is Kotlin-first, built on coroutines and Okio, and ships Compose composables like AsyncImage. Glide is the older, more mature library, Java-based and built around Views and RecyclerView, with deep lifecycle integration and a BitmapPool that aggressively reuses bitmap allocations. Both solve the same three problems: load asynchronously off the main thread, cache aggressively so scrolling never refetches an image you've already seen, and cancel or replace requests that are tied to a specific view. Everything else in this lesson is really just working through those three problems one at a time.
In Jetpack Compose, the simplest way to show a remote image with Coil is a single composable call. AsyncImage takes a model, usually a URL, and a content description, and that's the whole request. Under the hood it launches the load on a coroutine scoped to the composition, so when the composable leaves composition the load is cancelled automatically, there's no manual cleanup to write. That one line replaces what used to be several lines of manual bitmap loading, thread switching, and view binding.
AsyncImage(
model = "https://example.com/photo.jpg",
contentDescription = "Remote photo",
modifier = Modifier.size(64.dp)
)
AsyncImage isn't the only way to consume an image in Compose. If you need a raw Painter to hand to a composable like Icon, or to draw yourself in a Canvas, use rememberAsyncImagePainter instead, it returns the Painter without laying anything out for you. And when placeholder or error drawables aren't enough, when you need genuinely different composables for the loading, success, and error states, reach for SubcomposeAsyncImage. It uses subcomposition internally so each state can render its own arbitrary content. That flexibility isn't free: subcomposition is more expensive than AsyncImage's custom layout, and it's a poor fit for sizing items inside a LazyColumn, where cheap, predictable layout matters.
val painter = rememberAsyncImagePainter(url)
Icon(painter = painter, contentDescription = null)
SubcomposeAsyncImage(model = url, contentDescription = null) {
when (painter.state) {
is AsyncImagePainter.State.Loading -> CircularProgressIndicator()
is AsyncImagePainter.State.Error -> Icon(Icons.Default.BrokenImage, null)
else -> SubcomposeAsyncImageContent()
}
}
A decoded bitmap's memory cost is roughly its width times its height times the bytes used per pixel, and that cost is paid at the image's full pixel dimensions no matter how small the view showing it is. Decode a four thousand by three thousand pixel photo into a sixty-four density-independent pixel thumbnail without downsampling, and you've allocated tens of megabytes of heap for a handful of visible pixels. Do that for a list with dozens of images in flight and you have a textbook OutOfMemoryError. Both Coil and Glide handle this automatically: they downsample during decode using an inSampleSize-style trick, so the decoded bitmap lands close to the target view or requested size instead of the source image's full size. This is exactly why you should almost never call BitmapFactory.decodeFile yourself for a thumbnail, doing so throws away that optimization entirely.
// BAD: decodes the full source resolution regardless of the target view
val bitmap = BitmapFactory.decodeFile(path)
// GOOD: Coil downsamples to the size you actually need
imageView.load(url) {
size(128, 128)
}
Both libraries use a two-tier cache. A fast in-memory cache holds bitmaps that have already been decoded; a slower on-disk cache holds the original or transformed bytes. A lookup checks the fastest tier first: memory, then disk, and only then does it fall all the way to the network. Each tier down is progressively more expensive, so the ordering isn't arbitrary, it's cost-ordered, cheapest first.
The memory cache key isn't just the URL. It also includes the requested size and any transformations applied to the image. Load the same URL once plain and once with a CircleCropTransformation applied, and you get two separate cache entries, not one bitmap shared between them. That's deliberate: a circle-cropped bitmap and the original are different pixels, they can't share a cache slot even though they came from the same source bytes.
imageView.load(url)
imageView.load(url) {
transformations(CircleCropTransformation())
}
In a RecyclerView, rows get reused as you scroll. View holder three's ImageView might get rebound to a completely different item before its old image request has even finished. If that stale request finishes late and just sets whatever bitmap it fetched, you get the classic wrong-image bug: a photo meant for row three appearing under row seven's text. Both libraries prevent this the same way, by tying the request to the target ImageView itself. Starting a new load on that view cancels whatever request was previously in flight for it, so you never have to manually track or cancel anything yourself, binding a new URL to the view is the cancellation mechanism.
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val url = items[position].thumbnailUrl
holder.imageView.load(url)
}
Glide adds something Coil doesn't have at the call site: lifecycle awareness. Calling Glide.with(fragment) or Glide.with(activity) binds the whole request to that component's lifecycle, on top of the per-view cancellation you just saw. The request pauses when the host stops, and it's cancelled and cleared when the host is destroyed, so a Fragment that's been torn down can't leak a bitmap load that outlives it.
Glide.with(fragment)
.load(imageUrl)
.into(imageView)
Glide also exposes DiskCacheStrategy to control exactly what lands on disk: NONE writes nothing, DATA caches the original bytes, RESOURCE caches the decoded or transformed result, and ALL caches both. The default is AUTOMATIC, which picks intelligently based on the data source, typically caching the original bytes for a remote image so the same source can still be re-transformed differently later, while handling local or already-cheap sources differently.
Glide.with(context)
.load(remoteUrl)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.into(imageView)
Zooming out, the two libraries reflect two different eras of Android. Glide is Java-based and View-oriented, built around explicit callback and RequestBuilder APIs, with a BitmapPool that aggressively reuses bitmap byte arrays to cut garbage collection pressure in scrolling lists. Coil is Kotlin and coroutine-based, Compose-first, and lighter weight, and its newest major version even supports Compose Multiplatform, running beyond Android entirely. Neither one is written in some exotic shared language, and neither one is limited to only loading local files, both can load a plain network URL out of the box.
Because every ImageLoader owns its own memory and disk caches, creating a new one per request or per screen means duplicate caches wasting memory for no benefit. The recommended pattern is a single shared ImageLoader configured once for the whole app, which is exactly why Coil exposes a default singleton and lets you customize it through ImageLoaderFactory on your Application class.
class MyApp : Application(), ImageLoaderFactory {
override fun newImageLoader() = ImageLoader.Builder(this)
.crossfade(true)
.build()
}
On supported API levels, Coil decodes into Bitmap.Config.HARDWARE by default. A hardware bitmap's pixels live in graphics memory rather than the Java heap, which eases both heap pressure and garbage collector churn, especially valuable in a scrolling list full of images. The catch is that you cannot read a hardware bitmap's pixels on the CPU. Anything that needs pixel access, a custom software transformation, a getPixel call, saving raw bytes out, forces a software bitmap config instead. Most apps never touch this setting, AsyncImage and load just work by default, but knowing why a getPixel call crashes on an otherwise normal image load is a good signal in an interview that you actually understand the mechanism, not just the API name.
imageView.load(url) {
bitmapConfig(Bitmap.Config.ARGB_8888)
}
Coil 3 pulled the HTTP networking layer out of the core library and into optional artifacts, coil-network-okhttp or coil-network-ktor, rather than hard-coding one networking stack. That decoupling is what let Coil run on Compose Multiplatform targets beyond Android, since Ktor works across platforms where OkHttp doesn't. You still get networking out of the box, you just choose which backend to depend on.
implementation("io.coil-kt.coil3:coil-compose:3.+")
implementation("io.coil-kt.coil3:coil-network-ktor:3.+")