Skip to content
Go back

Warm Tyres: Image Loading and Caching in Compose Multiplatform with Coil 3

by KMP Bits

KMP Bits Cover

In the garage before a session, the tyres sit wrapped in electric blankets. Tyre warmers. They hold the rubber at around ninety degrees so that when the car rolls out of the box, the driver has grip on the very first corner instead of three laps later. Cold tyres are slow and unpredictable. A warm tyre is ready the moment it touches the track.

That’s what a good image cache does for your UI. A warm cache means the picture is on screen the instant the row scrolls into view. A cold cache means a network round trip, a blank box, and a user watching a spinner.

For years on Android I reached for Coil without thinking about it. Then I moved a feed screen into commonMain for a KMP app and hit the obvious question: what loads the images on iOS? I’d been assuming I’d need an expect/actual wrapper around two different libraries, Coil on Android and something Swift on iOS, with a shared interface bolted on top. I started sketching that abstraction. Then I checked the Coil 3 docs and deleted it.

Coil 3 is Kotlin Multiplatform. One AsyncImage call in commonMain renders on Android, iOS, desktop, and web. No expect/actual. No second library. The same memory cache, the same disk cache, the same loading code.


What changed in Coil 3

Coil 2 was Android only. It leaned on okhttp, the Android Drawable system, and Context everywhere. None of that exists in commonMain.

Coil 3 was rewritten to remove those assumptions. The core is now multiplatform, the artifact coordinates changed from io.coil-kt to io.coil-kt.coil3, and the parts that used to be hardwired to Android are pluggable. Networking is the clearest example. Coil 3 ships no HTTP client by default. You pick one: a Ktor 3 engine that works on every platform, or okhttp if you’re Android only and want to keep it. The image loader itself doesn’t care where the bytes come from.

The API you already know survived the move. AsyncImage, rememberAsyncImagePainter, ImageLoader, ImageRequest all still exist and still read the same way. If you’ve used Coil on Android, the Compose Multiplatform version will feel like the same tool with the Android-shaped corners filed off.


Adding the dependencies

Three pieces go into the multiplatform module. The Compose integration, a network fetcher, and a Ktor engine per platform.

# libs.versions.toml

[versions]
coil = "3.5.0"
ktor = "3.5.0"

[libraries]
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }

The split matters. coil-compose and coil-network-ktor3 go in commonMain. The Ktor engine is platform specific, because each platform talks to the network through its own stack: OkHttp on Android, the Darwin URLSession on iOS.

// build.gradle.kts

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.coil.compose)
            implementation(libs.coil.network.ktor)
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.okhttp)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

That’s the whole platform-specific surface. Two engine dependencies. Everything else is shared.


The simplest thing that works

Loading one image is a single composable in commonMain:

@Composable
fun Avatar(url: String) {
    AsyncImage(
        model = url,
        contentDescription = "User avatar",
        modifier = Modifier.size(48.dp).clip(CircleShape),
        contentScale = ContentScale.Crop,
    )
}

This compiles and runs on Android and iOS from the same file. AsyncImage handles the fetch, the decode, the caching, and the recomposition when the bytes arrive. You don’t touch a Context, a UIImage, or a Bitmap.

For most lists, this is all you need. The default ImageLoader already gives you a memory cache and a disk cache. The tyres come pre-warmed.


Configuring the loader (and why you probably don’t need to)

Here’s the part that caught me off guard. I didn’t have to configure anything. The default ImageLoader already gives you both a memory cache and a disk cache, and on every platform it picks a sensible cache directory on its own. Android lands in the app cache dir, iOS in its caches directory. No expect/actual, no path code, no startup hook. That’s why my first feed screen just worked.

So before you write a single line of cache config, check whether you actually need it. For most apps you don’t.

When you do want to change a default, Coil 3 uses a singleton ImageLoader you set once at startup through SingletonImageLoader.setSafe. The common reason is to give the in-memory cache a different budget:

// commonMain

fun initImageLoader(context: PlatformContext) {
    SingletonImageLoader.setSafe {
        ImageLoader.Builder(context)
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(context, 0.25)
                    .build()
            }
            .build() // disk cache left at its default
    }
}

maxSizePercent(context, 0.25) caps the in-memory cache at a quarter of the app’s available memory. That’s the fast tier: decoded bitmaps held in RAM, returned instantly on the next scroll past. Leave the disk cache untouched and Coil keeps its default location and size, which is fine for the vast majority of apps.

PlatformContext is Coil’s multiplatform stand-in for Android’s Context. On Android it’s a type alias for the real thing. On non-Android platforms you get one from PlatformContext.INSTANCE, or LocalPlatformContext.current inside a composable.

The one case where a platform path shows up

There’s exactly one situation where you’d reach for expect/actual: you want to pin the disk cache to a specific directory or change its size budget. Building a custom DiskCache means handing it a real directory, and that path differs per platform. That’s the right place for expect/actual, and it’s the only platform-specific code the whole feature ever needs:

// commonMain
expect fun cacheDir(): Path

// androidMain
actual fun cacheDir(): Path = appContext.cacheDir.toOkioPath()

// iosMain
actual fun cacheDir(): Path {
    val paths = NSFileManager.defaultManager.URLsForDirectory(
        NSCachesDirectory,
        NSUserDomainMask,
    )
    val url = paths.first() as NSURL
    return url.path!!.toPath()
}

Loading states without a blank box

A spinner over an empty square is the lazy default. You can do better by giving Coil explicit slots for the loading, error, and success states.

@Composable
fun FeedImage(url: String) {
    SubcomposeAsyncImage(
        model = ImageRequest.Builder(LocalPlatformContext.current)
            .data(url)
            .crossfade(true)
            .build(),
        contentDescription = null,
        modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
        loading = {
            Box(Modifier.fillMaxSize().background(Color(0xFF1A1A1A)))
        },
        error = {
            Box(Modifier.fillMaxSize().background(Color(0xFF2A1A1A))) {
                Icon(
                    Icons.Default.BrokenImage,
                    contentDescription = "Failed to load",
                    modifier = Modifier.align(Alignment.Center),
                )
            }
        },
    )
}

crossfade(true) fades the image in over a couple hundred milliseconds instead of popping it on. It’s a small touch that makes a warm cache and a cold fetch look less jarringly different.

A word of warning on SubcomposeAsyncImage. It’s more expensive than AsyncImage because it subcomposes its content, and in a long fast-scrolling list that cost adds up. I use it for hero images and detail screens. For dense list rows I stick with plain AsyncImage and a solid placeholder colour passed through the request. Reach for subcomposition when you actually need per-state layout, not as the default.


Preloading: warming the tyres before the green flag

Here’s where the analogy stops being decorative. If you know the user is about to scroll into a set of images, you can fetch and cache them before they’re on screen, so the cache is warm when the row arrives.

suspend fun preload(context: PlatformContext, urls: List<String>) {
    val loader = SingletonImageLoader.get(context)
    urls.forEach { url ->
        val request = ImageRequest.Builder(context)
            .data(url)
            .build()
        loader.enqueue(request)
    }
}

enqueue kicks off the fetch without rendering anything. The result lands in the memory and disk cache. When AsyncImage later asks for the same URL, it’s a cache hit and the image appears immediately.

I use this on detail screens that I know the user is heading toward. When a feed item is tapped, I preload the next likely images while the navigation transition is still animating. By the time the screen settles, the tyres are warm. Don’t preload your entire feed though. You’ll thrash the cache and waste the user’s data on images they never see. Preload the next handful, not the whole grid.


Gotchas I ran into

The missing network fetcher. If images silently fail to load and you get no useful error, the first thing to check is whether you actually added a Ktor engine to each platform source set. Coil 3 ships without one. With no engine, the network fetch has nothing to run on, and on iOS in particular the failure is quiet. Two lines in iosMain and androidMain fix it.

maxSizePercent vs a fixed size. On iOS the available-memory number behaves differently than on Android, and a percentage that’s comfortable on a phone with 8 GB can be aggressive on an older device. If you see memory warnings on iOS, switch the memory cache to a fixed maxSizeBytes and tune it down. Test on a real older device, not just the simulator.

Stable cache keys. If your image URLs carry a changing query parameter, a signed token or a cache-busting timestamp, Coil treats each variation as a different image and your cache hit rate collapses. Set an explicit memoryCacheKey and diskCacheKey on the request, based on the stable part of the URL, so the same picture maps to the same key every time.

ImageRequest.Builder(context)
    .data(signedUrl)
    .memoryCacheKey(stableImageId)
    .diskCacheKey(stableImageId)
    .build()

Compose previews. AsyncImage won’t render real images in a preview because there’s no network. Pass a placeholder so your previews show something instead of an empty rectangle.


Wrapping up

The thing that surprised me most wasn’t any single feature. It was how little there was to do. I came in expecting to write an abstraction layer over two image libraries and ended up deleting the interface I’d started. Coil 3 took a problem I assumed would need an expect/actual bridge and made it one shared composable, with caching included and no platform code at all. The only platform-specific thing you have to add is the Ktor engine, two lines per source set. The cache directory expect/actual is optional, and most apps never write it.

The cache tiers map cleanly onto how you should think about it. Memory cache is your warm tyre, instant grip on the next scroll. Disk cache survives a restart so cold launches aren’t cold all the way down. Preloading is warming the tyres before the green flag, paid for with a little foresight about where the user is going next.

Add a Ktor engine, keep your cache keys stable, and the images are ready the moment the row touches the screen. The defaults already warm the tyres for you. First corner, full grip.

Keep it flat out. 🏁


The full demo for this article is available on GitHub.

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Through the Lens: Barcode Scanning in Compose Multiplatform