Skip to main content

Image Loading — Coil + Glide

Loading images on Android is not "set bitmap on ImageView." A production-grade image stack handles caching (memory + disk), placeholders, transformations, error handling, dynamic resizing, animated GIF/WebP, prefetching, and recycler-view-style reuse. Coil is the modern Compose-first choice; Glide remains common in View-based code.

Coil vs Glide

Coil

Modern; Compose-first

  • Kotlin-first, coroutines + Flow native
  • AsyncImage composable — one-line Compose usage
  • ~700 KB; smaller than Glide
  • Multiplatform — also runs on iOS via Coil 3
  • First-class GIF + WebP + video frame extraction
  • Cleaner cache API
Glide

Battle-tested; legacy-friendly

  • Java-based; works seamlessly with View / RecyclerView
  • Mature for over a decade
  • GIF, WebP, MP4 thumbnail support
  • Per-request lifecycle binding
  • Custom ModelLoaders for any data type
  • Better fits XML / Fragment migration paths

For Compose-only apps: use Coil. For mixed Compose + Views or existing Glide codebases: Glide is fine.


Coil setup

// libs.versions.toml
coil = "3.0.4"

coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil" }
@Singleton
class AppImageLoaderProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient
) {
val imageLoader: ImageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(okHttpClient)) // share OkHttp with API calls
add(GifDecoder.Factory())
add(VideoFrameDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.15) // 15% of heap
.build()
}
.diskCache {
DiskCache.Builder()
.directory(File(context.cacheDir, "image_cache"))
.maxSizeBytes(150L * 1024 * 1024) // 150 MB
.build()
}
.crossfade(durationMillis = 300)
.build()

fun install() {
SingletonImageLoader.setSafe { imageLoader }
}
}

// Call install() from your Application.onCreate

Sharing the OkHttp client means image requests benefit from your auth interceptor, cache, certificate pinning. No duplicate connection pools.


Coil — basic Compose usage

AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier
.size(80.dp)
.clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.error_image)
)

Loading state composables

SubcomposeAsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> Box(Modifier.fillMaxSize()) {
CircularProgressIndicator(Modifier.align(Alignment.Center).size(24.dp))
}
is AsyncImagePainter.State.Error -> Icon(Icons.Default.BrokenImage, null)
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
else -> Unit
}
}

SubcomposeAsyncImage is more expensive per-item than AsyncImage. Use plain AsyncImage + placeholder / error painters in lists; reserve SubcomposeAsyncImage for hero images.

Request builder for advanced cases

val request = ImageRequest.Builder(LocalContext.current)
.data(product.imageUrl)
.size(80, 80) // exact decode size — saves memory
.crossfade(true)
.placeholder(R.drawable.placeholder)
.error(R.drawable.error)
.listener(
onSuccess = { _, result -> analytics.log("image_loaded", result.dataSource) },
onError = { _, error -> Log.e("Image", "failed", error.throwable) }
)
.build()

AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.size(80.dp)
)

Transformations

implementation("io.coil-kt.coil3:coil-svg:3.0.4") // SVG support

AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(avatarUrl)
.transformations(
CircleCropTransformation(),
BlurTransformation(radius = 4f, sampling = 1f)
)
.build(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)

Built-in transformations:

  • CircleCropTransformation — round avatars
  • RoundedCornersTransformation(radius) — rounded corners (alternative: Modifier.clip)
  • BlurTransformation(radius, sampling) — Gaussian blur
  • GrayscaleTransformation — desaturate

Custom transformation:

class ColorMatrixTransformation(private val matrix: ColorMatrix) : Transformation {
override val cacheKey = "color-matrix-${matrix.hashCode()}"

override suspend fun transform(input: Bitmap, size: Size): Bitmap {
val output = Bitmap.createBitmap(input.width, input.height, input.config!!)
Canvas(output).drawBitmap(input, 0f, 0f, Paint().apply {
colorFilter = ColorMatrixColorFilter(matrix)
})
return output
}
}

Memory + disk cache management

Manual cache control

val request = ImageRequest.Builder(context)
.data(url)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.networkCachePolicy(CachePolicy.ENABLED)
.build()

Policies:

  • ENABLED — read + write
  • READ_ONLY — only read; don't write
  • WRITE_ONLY — write but skip read (force network)
  • DISABLED — neither

For "user pulled-to-refresh," skip cache:

.memoryCachePolicy(CachePolicy.DISABLED)
.networkCachePolicy(CachePolicy.WRITE_ONLY)

Clearing cache

fun clearImageCache() {
val loader = SingletonImageLoader.get(context)
loader.memoryCache?.clear()
loader.diskCache?.clear()
}

Run on sign-out (clear other users' avatars), low-memory callbacks, or debug menu.

Cache key

By default, the URL is the cache key. Override for custom logic:

.memoryCacheKey("product-${id}-${size}")
.diskCacheKey("product-${id}-${size}")

Useful when the same image is loaded at multiple sizes and you don't want re-downloads.


Prefetching

// In a ViewModel, prefetch the next page of images
val loader = SingletonImageLoader.get(context)
val urls = nextPageProducts.map { it.imageUrl }
urls.forEach { url ->
loader.enqueue(
ImageRequest.Builder(context)
.data(url)
.memoryCachePolicy(CachePolicy.WRITE_ONLY)
.build()
)
}

Prefetch images for the next page before the user scrolls — feels instant.


Custom fetcher / Mapper

For non-URL sources (Firebase Storage references, base64 strings, your own binary protocol), implement Fetcher and Mapper:

class FirebaseStorageFetcher(
private val ref: StorageReference,
private val options: Options
) : Fetcher {
override suspend fun fetch(): FetchResult {
val bytes = ref.getBytes(MAX_BYTES).await()
return SourceFetchResult(
source = ImageSource(source = ByteBuffer.wrap(bytes).asSource(), context = options.context),
mimeType = "image/jpeg",
dataSource = DataSource.NETWORK
)
}

class Factory : Fetcher.Factory<StorageReference> {
override fun create(data: StorageReference, options: Options, imageLoader: ImageLoader) =
FirebaseStorageFetcher(data, options)
}
}

// Register in ImageLoader
ImageLoader.Builder(context)
.components {
add(FirebaseStorageFetcher.Factory())
}
.build()

Now you can pass a StorageReference directly to AsyncImage(model = storageRef).


Animated content

implementation("io.coil-kt.coil3:coil-gif:3.0.4")
implementation("io.coil-kt.coil3:coil-video:3.0.4")

// Add to ImageLoader
.components {
add(GifDecoder.Factory())
add(VideoFrameDecoder.Factory())
}

GIF / animated WebP: just point AsyncImage(model = gifUrl). Decoder auto-detected; animation plays.

Video frame extraction:

AsyncImage(
model = ImageRequest.Builder(context)
.data(videoUri)
.videoFrameMillis(2_000L) // frame at 2 seconds
.build(),
contentDescription = null
)

Extract a thumbnail from a video file. Saves storage vs pre-generating thumbnails.


Lifecycle management

@Composable
fun ProductImage(product: Product) {
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier.size(80.dp)
)
// Coil disposes the request automatically when this composable leaves composition
}

Coil auto-cancels in-flight requests when the composable leaves the composition. No manual cleanup needed.

For long-lived loads (background processing):

val loader = SingletonImageLoader.get(context)
val deferred = loader.enqueue(request) // returns Disposable
// ... later
deferred.dispose()

Performance tips

Always set size

// ❌ Decodes at full resolution — wasteful
AsyncImage(model = url, contentDescription = null)

// ✅ Decodes at display size
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(80.dp)
)

Coil auto-detects the target size from Modifier.size(). Without size hints, it decodes at original resolution — a 4MB photo becomes 50MB in memory. Always constrain.

Use ContentScale.Crop with explicit size

AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.fillMaxWidth().aspectRatio(16f/9f),
contentScale = ContentScale.Crop
)

Cropping at decode time is way cheaper than rendering a giant image and clipping.

Disable crossfade in dense lists

val itemRequest = ImageRequest.Builder(context)
.data(url)
.size(80, 80)
.crossfade(false) // crossfade in a scroll list looks bad anyway
.build()

Saves a few ms per item.

Color filter — avoid GPU shader for tints

// Cheaper than ColorFilter.tint on the Image composable
.transformations(ColorFilterTransformation(MaterialTheme.colorScheme.primary))

Pre-tinted bitmaps in cache vs per-frame tinting.


Glide quickstart (legacy View / mixed)

// libs.versions.toml
glide = { module = "com.github.bumptech.glide:glide", version = "4.16.0" }
glide-compiler = { module = "com.github.bumptech.glide:ksp", version = "4.16.0" }
// In a Fragment / Activity
Glide.with(this)
.load(product.imageUrl)
.placeholder(R.drawable.placeholder)
.error(R.drawable.error)
.centerCrop()
.into(imageView)

For RecyclerView ViewHolders:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
Glide.with(holder.itemView.context)
.load(items[position].imageUrl)
.into(holder.imageView)
}

override fun onViewRecycled(holder: ViewHolder) {
Glide.with(holder.itemView.context).clear(holder.imageView)
super.onViewRecycled(holder)
}

Glide.with(context).clear(view) is critical in RecyclerView — prevents mismatched images on scroll.

Glide custom config

@GlideModule
class AppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
builder.setDefaultRequestOptions(
RequestOptions()
.format(DecodeFormat.PREFER_RGB_565) // 50% memory vs ARGB_8888
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
)
builder.setMemoryCache(LruResourceCache(20 * 1024 * 1024))
builder.setDiskCache(DiskLruCacheFactory(File(context.cacheDir, "glide"), 100L * 1024 * 1024))
}

override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(okHttpClient))
}
}

Common anti-patterns

Anti-patterns

Image loading mistakes

  • AsyncImage without size hints (OOM)
  • Loading raw bytes into ImageView.setImageBitmap (no cache)
  • Per-request OkHttpClient (no connection reuse)
  • Decoding to ARGB_8888 when RGB_565 suffices
  • Crossfade in long lists (visual mess)
  • No clear(view) in RecyclerView onViewRecycled
Best practices

Production images

  • Explicit Modifier.size + ContentScale.Crop
  • Coil/Glide with shared cache + OkHttp
  • Single shared OkHttp + ImageLoader
  • RGB_565 for solid photos; ARGB_8888 for transparency
  • crossfade(false) in scroll lists
  • Glide.clear(view) on recycle in RecyclerView

Key takeaways

Practice exercises

  1. 01

    Share OkHttp with Coil

    Wire your auth-interceptor OkHttp client into Coil's ImageLoader. Verify auth headers appear on image requests.

  2. 02

    Sized AsyncImage

    Audit a screen with AsyncImage. Add explicit Modifier.size where missing. Measure memory before/after.

  3. 03

    Custom transformation

    Write a SepiaTransformation that applies a color matrix. Use it in a photo viewer.

  4. 04

    Prefetch next page

    In a Paging-driven list, enqueue ImageRequests for the next page when the user is 5 items from the end.

  5. 05

    Firebase Storage fetcher

    Implement FirebaseStorageFetcher so AsyncImage(model = storageRef) works directly with Firebase References.

Next

Return to Module 06 Overview or continue to Module 07 — Firebase & Cloud.