Every app shows images — user avatars, article covers, product photos, banners. But loading images is harder than it looks. The image is on a server. You need to download it, decode the bitmap, resize it to fit the ImageView, cache it so you don’t re-download it, handle errors, show a placeholder while loading, and do all of this without freezing the UI or running out of memory. Image loading libraries do all of this in one line. Coil is the modern choice for Compose. Glide is the established choice for XML Views. This guide covers both.


The Mental Model — Why You Need a Library

// Loading an image WITHOUT a library:
//
// 1. Start a background thread (can't download on main thread)
// 2. Open an HTTP connection to the image URL
// 3. Download the bytes (might be 5 MB for a high-res photo)
// 4. Decode bytes into a Bitmap (might use 20 MB of RAM uncompressed!)
// 5. Resize the Bitmap to fit the View (200x200dp, not 4000x3000 pixels)
// 6. Set the Bitmap on the ImageView (on the main thread)
// 7. Handle: no internet, 404, corrupted image, out of memory
// 8. Cache: don't re-download if the user scrolls back
// 9. Cancel: if the user scrolled past before download finished
//
// That's 50+ lines of complex, error-prone code PER IMAGE
//
// Loading an image WITH a library:
//
// AsyncImage(model = url, contentDescription = null)
//
// ONE LINE. The library handles ALL of the above.
// Memory management, caching, threading, resizing, error handling — everything.

Coil — The Modern Choice for Compose

Coil (Coroutine Image Loader) is built by Instacart, written in Kotlin, and designed specifically for Compose and coroutines. It’s the recommended image loader for new Android projects.

Setup

// build.gradle.kts
dependencies {
    // Coil for Compose (includes core Coil + Compose integration)
    implementation("io.coil-kt:coil-compose:2.7.0")
    // coil-compose is a LIBRARY that provides AsyncImage and other Compose components

    // Optional: GIF support
    implementation("io.coil-kt:coil-gif:2.7.0")

    // Optional: SVG support
    implementation("io.coil-kt:coil-svg:2.7.0")

    // Optional: Video frame thumbnails
    implementation("io.coil-kt:coil-video:2.7.0")
}

Basic usage — one line

// The simplest way to load an image in Compose:

@Composable
fun ArticleImage(imageUrl: String) {
    AsyncImage(
        model = imageUrl,
        // model — the image source (URL string, Uri, File, or ImageRequest)
        // AsyncImage figures out what to do based on the type
        contentDescription = "Article cover image"
        // contentDescription — for accessibility (screen readers)
        // Use null only for purely decorative images
    )
}

// That's it! Coil handles:
// ✅ Download from URL on a background thread
// ✅ Decode the image bytes into a bitmap
// ✅ Resize to fit the composable's size
// ✅ Display the image
// ✅ Cache in memory AND on disk
// ✅ Cancel if the composable leaves the Composition

Placeholders, errors, and content scaling

@Composable
fun ArticleCoverImage(imageUrl: String?, modifier: Modifier = Modifier) {
    AsyncImage(
        // AsyncImage is a COMPOSABLE FUNCTION from coil-compose
        model = ImageRequest.Builder(LocalContext.current)
            // ImageRequest.Builder is a CLASS from Coil
            // Lets you configure the image load with full control
            .data(imageUrl)
            // data() is a FUNCTION on Builder — the image source
            .crossfade(true)
            // crossfade() is a FUNCTION on Builder — fade-in animation when image loads
            // Default duration: 200ms. Custom: crossfade(500)
            .placeholder(R.drawable.placeholder_article)
            // placeholder() is a FUNCTION on Builder
            // Shown WHILE the image is loading (a gray box, shimmer, etc.)
            .error(R.drawable.error_image)
            // error() is a FUNCTION on Builder
            // Shown if loading FAILS (no internet, 404, corrupted image)
            .fallback(R.drawable.default_cover)
            // fallback() is a FUNCTION on Builder
            // Shown if the data is NULL (imageUrl is null)
            // Different from error: fallback = null data, error = failed load
            .build(),
            // build() creates the ImageRequest
        contentDescription = "Article cover",
        contentScale = ContentScale.Crop,
        // ContentScale is a CLASS from compose.ui.layout
        // Crop — scales to fill and crops edges (like centerCrop in XML)
        // Fit — scales to fit inside without cropping (like fitCenter)
        // FillBounds — stretches to fill (distorts aspect ratio)
        // Inside — scales down to fit, doesn't scale up
        // None — no scaling
        modifier = modifier
            .fillMaxWidth()
            .height(200.dp)
            .clip(RoundedCornerShape(12.dp))
            // clip() is an EXTENSION FUNCTION on Modifier — clips to shape
    )
}

Circle avatar

@Composable
fun UserAvatar(avatarUrl: String?, size: Dp = 48.dp) {
    AsyncImage(
        model = ImageRequest.Builder(LocalContext.current)
            .data(avatarUrl)
            .crossfade(true)
            .placeholder(R.drawable.avatar_placeholder)
            .error(R.drawable.avatar_default)
            .transformations(CircleCropTransformation())
            // transformations() is a FUNCTION on Builder
            // CircleCropTransformation is a CLASS from Coil
            // Crops the image into a perfect circle
            // Applied AFTER download, BEFORE display
            .build(),
        contentDescription = "User avatar",
        modifier = Modifier
            .size(size)
            .clip(CircleShape)
            // CircleShape is a VAL from compose.foundation.shape
    )
}

// Other built-in transformations:
// RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight)
// BlurTransformation(context, radius, sampling)
// GrayscaleTransformation()

// Multiple transformations:
.transformations(
    CircleCropTransformation(),
    GrayscaleTransformation()   // grayscale circle!
)

SubcomposeAsyncImage — custom loading/error composables

// When you want full control over loading/error/success states with Compose:

@Composable
fun ArticleImageWithStates(imageUrl: String?) {
    SubcomposeAsyncImage(
        // SubcomposeAsyncImage is a COMPOSABLE FUNCTION from coil-compose
        // Unlike AsyncImage (which uses drawables for placeholder/error),
        // SubcomposeAsyncImage lets you use COMPOSABLE FUNCTIONS for each state
        model = imageUrl,
        contentDescription = "Article cover",
        modifier = Modifier.fillMaxWidth().height(200.dp)
    ) {
        when (painter.state) {
            // painter is a PROPERTY — the current AsyncImagePainter
            // state is a PROPERTY on AsyncImagePainter — current loading state
            // AsyncImagePainter.State is a SEALED CLASS

            is AsyncImagePainter.State.Loading -> {
                // Show a shimmer/loading composable while image loads
                Box(
                    modifier = Modifier.fillMaxSize().background(Color.LightGray),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator(modifier = Modifier.size(24.dp))
                }
            }
            is AsyncImagePainter.State.Error -> {
                // Show error composable
                Box(
                    modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5)),
                    contentAlignment = Alignment.Center
                ) {
                    Icon(Icons.Default.BrokenImage, "Error", tint = Color.Gray)
                }
            }
            else -> {
                // Success — show the image
                SubcomposeAsyncImageContent()
                // SubcomposeAsyncImageContent is a COMPOSABLE FUNCTION
                // Renders the loaded image — call this in the success state
            }
        }
    }
}

Coil in LazyColumn (lists)

// Coil handles LazyColumn efficiently out of the box:
// - Cancels loading when items scroll off screen
// - Reuses cached images when items scroll back
// - Memory cache prevents re-decoding

LazyColumn {
    items(articles, key = { it.id }) { article ->
        Row(modifier = Modifier.padding(16.dp)) {
            AsyncImage(
                model = article.imageUrl,
                contentDescription = null,
                modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)),
                contentScale = ContentScale.Crop
            )
            Spacer(Modifier.width(12.dp))
            Column {
                Text(article.title, style = MaterialTheme.typography.titleMedium)
                Text(article.author, style = MaterialTheme.typography.bodySmall)
            }
        }
    }
}
// Coil automatically:
// ✅ Loads visible images
// ✅ Cancels loads for items that scrolled away
// ✅ Returns cached images for items that scroll back
// No special configuration needed!

How Coil’s Cache Works

// Coil has a TWO-LEVEL cache:
//
//  Request: "Load https://example.com/photo.jpg"
//
//  Level 1: MEMORY CACHE (fastest)
//  ┌──────────────────────────┐
//  │ Is the decoded Bitmap    │
//  │ already in RAM?          │
//  │ YES → return immediately │  ← ~0ms, no decoding needed
//  │ NO → check disk cache   │
//  └──────────────────────────┘
//
//  Level 2: DISK CACHE (fast)
//  ┌──────────────────────────┐
//  │ Is the image file stored │
//  │ on the device?           │
//  │ YES → decode and return  │  ← ~10-50ms, decode from file
//  │ NO → download from net   │
//  └──────────────────────────┘
//
//  Level 3: NETWORK (slow)
//  ┌──────────────────────────┐
//  │ Download from server     │
//  │ Save to disk cache       │  ← 100ms-5s depending on connection
//  │ Decode and save to       │
//  │ memory cache             │
//  │ Return Bitmap            │
//  └──────────────────────────┘
//
// Second time the same image is requested:
// Memory cache hit → instant! (~0ms)
//
// After app restart (memory cleared):
// Disk cache hit → fast! (~10-50ms, no network needed)

// Memory cache: ~25% of available app memory (default)
// Disk cache: ~250 MB (configurable)
// Both are LRU (Least Recently Used) — oldest images evicted first

Configuring the image loader

// Custom Coil configuration (optional — defaults are usually fine)

// In your Application class:
class MyApplication : Application(), ImageLoaderFactory {
    // ImageLoaderFactory is an INTERFACE from Coil
    // Implement it to provide a custom ImageLoader singleton

    override fun newImageLoader(): ImageLoader {
        // newImageLoader() is a FUNCTION on ImageLoaderFactory
        // Called once to create the global ImageLoader

        return ImageLoader.Builder(this)
            // ImageLoader.Builder is a CLASS from Coil
            .memoryCachePolicy(CachePolicy.ENABLED)
            // CachePolicy is an ENUM from Coil: ENABLED, READ_ONLY, WRITE_ONLY, DISABLED
            .memoryCache {
                MemoryCache.Builder(this)
                    // MemoryCache.Builder is a CLASS from Coil
                    .maxSizePercent(0.25)
                    // maxSizePercent() — use 25% of available app memory for image cache
                    .build()
            }
            .diskCache {
                DiskCache.Builder()
                    // DiskCache.Builder is a CLASS from Coil
                    .directory(cacheDir.resolve("image_cache"))
                    // directory() — where to store cached images on disk
                    .maxSizePercent(0.02)
                    // maxSizePercent() — use 2% of device storage (~200 MB on 10 GB device)
                    .build()
            }
            .crossfade(true)
            // Global crossfade — applies to all image loads
            .respectCacheHeaders(true)
            // respectCacheHeaders() — obey server's Cache-Control headers
            .build()
    }
}

// Cache control per request:
ImageRequest.Builder(context)
    .data(url)
    .memoryCachePolicy(CachePolicy.DISABLED)
    // Skip memory cache for THIS request (force fresh load)
    .diskCachePolicy(CachePolicy.READ_ONLY)
    // Read from disk but don't write new entries
    .build()

Glide — The Established Choice for XML Views

Glide is by Google/Bumptech and has been the standard for XML-based Android apps. Use it when you’re working with XML layouts, or in codebases that already use it.

Setup

// build.gradle.kts
dependencies {
    implementation("com.github.bumptech.glide:glide:4.16.0")
    // glide is a LIBRARY from Bumptech/Google
    ksp("com.github.bumptech.glide:ksp:4.16.0")
    // Glide's KSP annotation processor for @GlideModule

    // Optional: Compose integration
    implementation("com.github.bumptech.glide:compose:1.0.0-beta01")
}

Basic usage with XML Views

// Load an image into an ImageView:
Glide.with(context)
    // Glide is a CLASS from the Glide library
    // with() is a STATIC FUNCTION on Glide — creates a RequestManager
    // Takes: Context, Activity, Fragment, or View
    // Glide ties the image load to the LIFECYCLE of what you pass
    // Pass Fragment → load cancels when Fragment is destroyed
    // Pass Activity → load cancels when Activity is destroyed
    .load(imageUrl)
    // load() is a FUNCTION on RequestManager — what to load
    // Accepts: String (URL), Uri, File, Drawable resource, ByteArray
    .placeholder(R.drawable.placeholder)
    // placeholder() is a FUNCTION — shown while loading
    .error(R.drawable.error_image)
    // error() is a FUNCTION — shown if loading fails
    .centerCrop()
    // centerCrop() is a FUNCTION — scales and crops to fill the ImageView
    .into(binding.articleImage)
    // into() is a FUNCTION on RequestBuilder — the target ImageView
    // This starts the load — downloads, decodes, caches, displays

// That's one fluent chain — Glide does everything

Circle avatar with Glide

Glide.with(this)
    .load(user.avatarUrl)
    .circleCrop()
    // circleCrop() is a FUNCTION on RequestBuilder — crops into a circle
    .placeholder(R.drawable.avatar_placeholder)
    .error(R.drawable.avatar_default)
    .into(binding.avatarImage)

// Rounded corners:
Glide.with(this)
    .load(article.imageUrl)
    .transform(RoundedCorners(24))
    // transform() is a FUNCTION on RequestBuilder
    // RoundedCorners is a CLASS from Glide — rounds all corners
    // 24 = radius in pixels (use dpToPx for dp values)
    .into(binding.coverImage)

// Multiple transforms:
Glide.with(this)
    .load(url)
    .transform(CenterCrop(), RoundedCorners(24))
    // CenterCrop first, then RoundedCorners
    .into(binding.image)

Glide with Compose (if needed)

// If you're using Compose but prefer Glide over Coil:

@Composable
fun GlideArticleImage(imageUrl: String?) {
    GlideImage(
        // GlideImage is a COMPOSABLE FUNCTION from glide-compose
        model = imageUrl,
        contentDescription = "Article cover",
        modifier = Modifier.fillMaxWidth().height(200.dp),
        contentScale = ContentScale.Crop
    ) {
        it.placeholder(R.drawable.placeholder)
            .error(R.drawable.error_image)
            // Glide's request builder — same API as XML
    }
}

Coil vs Glide — Which to Choose

// ┌─────────────────────┬──────────────────────┬──────────────────────┐
// │                     │ Coil                 │ Glide                │
// ├─────────────────────┼──────────────────────┼──────────────────────┤
// │ Language             │ Kotlin-first         │ Java (Kotlin-usable) │
// │ Compose support      │ ✅ Native (best)     │ ✅ Via glide-compose  │
// │ XML Views support    │ ✅ Works             │ ✅ Excellent          │
// │ Coroutines           │ ✅ Built-in          │ ❌ Callback-based     │
// │ Library size         │ ~1500 methods        │ ~3000 methods        │
// │ GIF support          │ Via coil-gif         │ Built-in             │
// │ Video thumbnails     │ Via coil-video       │ Built-in             │
// │ Community            │ Growing              │ Massive, mature      │
// │ Maintained by        │ Instacart            │ Google/Bumptech      │
// │ First release        │ 2019                 │ 2014                 │
// │ OkHttp integration   │ Built-in (uses OkHttp│ Optional (uses       │
// │                     │ for networking)       │ HttpUrlConnection)   │
// │ Disk cache           │ ✅ Built-in          │ ✅ Built-in           │
// │ Memory cache         │ ✅ Built-in          │ ✅ Built-in           │
// └─────────────────────┴──────────────────────┴──────────────────────┘
//
// RECOMMENDATION:
// New Compose project → Coil (Kotlin-native, coroutines, smaller)
// Existing XML project → Glide (established, battle-tested, great XML support)
// Mixed Compose + XML → either works (Coil slightly better for Compose)
// Existing project with Glide → keep Glide (no reason to migrate)

Advanced Patterns

Preloading images

// Preload images that the user will likely see soon
// (e.g., preload the next page of articles while showing current page)

// Coil:
val context = LocalContext.current
LaunchedEffect(nextPageArticles) {
    nextPageArticles.forEach { article ->
        val request = ImageRequest.Builder(context)
            .data(article.imageUrl)
            .size(ViewSizeResolver(binding.imageView))
            // Preload at the size it will be displayed
            .build()
        context.imageLoader.enqueue(request)
        // imageLoader is an EXTENSION PROPERTY on Context (from Coil)
        // enqueue() is a FUNCTION on ImageLoader — loads in background without displaying
    }
}

// Glide:
nextPageArticles.forEach { article ->
    Glide.with(context)
        .load(article.imageUrl)
        .preload()
        // preload() is a FUNCTION on RequestBuilder — downloads and caches without displaying
}

Loading into a notification or widget

// Sometimes you need a Bitmap (not for an ImageView) — e.g., for a notification

// Coil — suspend function returns ImageResult:
suspend fun loadBitmapForNotification(context: Context, url: String): Bitmap? {
    val request = ImageRequest.Builder(context)
        .data(url)
        .size(200, 200)
        // size() is a FUNCTION on Builder — request a specific size
        .allowHardware(false)
        // allowHardware(false) — return a software Bitmap (needed for notifications)
        // Hardware Bitmaps can't be drawn on Canvas outside the GPU
        .build()

    val result = context.imageLoader.execute(request)
    // execute() is a SUSPEND FUNCTION on ImageLoader — loads synchronously (in coroutine)
    // Returns ImageResult — a SEALED CLASS (Success or Error)

    return (result as? SuccessResult)?.drawable?.toBitmap()
    // SuccessResult is a CLASS — contains the loaded drawable
    // toBitmap() is an EXTENSION FUNCTION on Drawable — converts to Bitmap
}

// Glide — use submit() for synchronous load:
val bitmap: Bitmap = withContext(Dispatchers.IO) {
    Glide.with(context)
        .asBitmap()
        // asBitmap() is a FUNCTION — load as Bitmap instead of Drawable
        .load(url)
        .submit(200, 200)
        // submit() is a FUNCTION — returns a FutureTarget (synchronous)
        .get()
        // get() blocks until the image is loaded — use on background thread only!
}

Clearing cache

// Coil:
// Clear memory cache (main thread):
context.imageLoader.memoryCache?.clear()
// memoryCache is a PROPERTY on ImageLoader — returns MemoryCache?
// clear() is a FUNCTION on MemoryCache

// Clear disk cache (background thread):
withContext(Dispatchers.IO) {
    context.imageLoader.diskCache?.clear()
    // diskCache is a PROPERTY on ImageLoader — returns DiskCache?
}

// Glide:
// Clear memory cache (main thread):
Glide.get(context).clearMemory()
// clearMemory() is a FUNCTION on Glide — clears the memory cache

// Clear disk cache (background thread):
withContext(Dispatchers.IO) {
    Glide.get(context).clearDiskCache()
    // clearDiskCache() is a FUNCTION on Glide — clears the disk cache
}

Common Mistakes to Avoid

Mistake 1: Loading full-size images into small Views

// ❌ Server sends 4000x3000 image, displayed in a 100x100dp avatar
// Decodes 48 MB bitmap just to display at thumbnail size — wastes memory!

// ✅ Both Coil and Glide automatically resize to the View/Composable size
// Just set the size on the Modifier/ImageView — the library handles the rest
AsyncImage(
    model = url,
    modifier = Modifier.size(48.dp),   // Coil loads at 48dp size, not 4000px
    contentDescription = null
)

// For custom sizes:
ImageRequest.Builder(context)
    .data(url)
    .size(200, 200)   // force specific size
    .build()

Mistake 2: Using Glide.with(applicationContext) in Activities/Fragments

// ❌ Application context — loads are NEVER cancelled (memory leak risk!)
Glide.with(applicationContext).load(url).into(imageView)
// If Activity is destroyed, the load continues uselessly

// ✅ Use the Activity or Fragment — loads cancelled automatically on lifecycle end
Glide.with(this).load(url).into(imageView)       // "this" = Activity
Glide.with(viewLifecycleOwner).load(url).into(imageView)   // Fragment

// In Compose, Coil handles this automatically — tied to the Composition lifecycle

Mistake 3: Not handling null/empty URLs

// ❌ Passing empty string — loads fail silently or show nothing
AsyncImage(model = "", contentDescription = null)

// ✅ Use fallback for null URLs, handle empty strings
AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(imageUrl.takeIf { !it.isNullOrBlank() })
        // If null or blank → data is null → fallback shown
        .fallback(R.drawable.default_image)
        .error(R.drawable.error_image)
        .build(),
    contentDescription = null
)

Mistake 4: Ignoring contentScale

// ❌ No contentScale — image might be stretched, squished, or not fill the area
AsyncImage(model = url, contentDescription = null, modifier = Modifier.size(200.dp))
// Default contentScale is ContentScale.Fit — might leave empty space

// ✅ Choose the right contentScale for your design
// Crop — fill the area, crop edges (most common for covers/banners)
AsyncImage(model = url, contentScale = ContentScale.Crop, ...)

// Fit — show entire image, might have letterboxing
AsyncImage(model = url, contentScale = ContentScale.Fit, ...)

Mistake 5: Clearing cache on the wrong thread

// ❌ Clearing disk cache on main thread — freezes the UI
Glide.get(context).clearDiskCache()   // 💥 blocks main thread!

// ✅ Disk cache operations on background thread
withContext(Dispatchers.IO) {
    Glide.get(context).clearDiskCache()   // ✅ background thread
}

// Memory cache is fine on main thread:
Glide.get(context).clearMemory()   // ✅ fast, main thread OK

Summary

  • Coil (library by Instacart) is the modern, Kotlin-first image loader — built for Compose and coroutines
  • Glide (library by Google/Bumptech) is the established image loader — excellent XML support, battle-tested
  • AsyncImage (composable function from coil-compose) loads and displays an image in one composable
  • SubcomposeAsyncImage (composable function) provides custom loading/error/success composables
  • ImageRequest.Builder (class from Coil) configures the load: data, placeholder, error, fallback, crossfade, transformations
  • Glide.with(context).load(url).into(imageView) is Glide’s fluent loading chain
  • CircleCropTransformation (Coil class) and circleCrop() (Glide function) crop images into circles
  • ContentScale (class from Compose): Crop (fill + clip), Fit (show all), FillBounds (stretch)
  • Both libraries have two-level cache: memory (fastest, in-RAM) and disk (persists across app restarts)
  • ImageLoaderFactory (interface from Coil) provides custom cache config via Application class
  • CachePolicy (enum from Coil): ENABLED, READ_ONLY, WRITE_ONLY, DISABLED
  • Preload with imageLoader.enqueue() (Coil function) or preload() (Glide function)
  • Get a Bitmap with imageLoader.execute() (Coil suspend function) or Glide.submit().get()
  • Use the right lifecycle context: Activity/Fragment for Glide, Composition handles Coil automatically
  • New Compose projects → Coil; existing XML projects → Glide; mixed → either works

Image loading sounds simple until you think about caching, memory, threading, cancellation, and error handling. Coil and Glide handle all of it — you provide a URL, they do the rest. Pick Coil for Compose, Glide for XML, write one line per image, and focus on building your app instead of managing bitmaps.

Happy coding!