Production-focused guidance for loading remote and local images in Jetpack Compose and Compose Multiplatform using Coil 3.
References:
Coil 3 does not include network loading by default. Add coil-compose and exactly one network integration.
// Shared for Compose UI
implementation("io.coil-kt.coil3:coil-compose:<version>")
// Android/JVM only
implementation("io.coil-kt.coil3:coil-network-okhttp:<version>")
// Multiplatform-friendly network options
implementation("io.coil-kt.coil3:coil-network-ktor2:<version>")
// or
implementation("io.coil-kt.coil3:coil-network-ktor3:<version>")
If you use Ktor networking, add platform engines for your targets (Android, Apple, JVM).
| Use case | Best API | Why |
|---|---|---|
| Most image rendering in UI | AsyncImage |
Best default; resolves image size from constraints |
Need a Painter or manual request restart/state observation |
rememberAsyncImagePainter |
More control, lower-level painter API |
| Need composable slots per loading state and need first-frame state correctness | SubcomposeAsyncImage |
Slot API with immediate state, but slower |
SubcomposeAsyncImage uses subcomposition and is generally less suitable for dense LazyColumn/LazyGrid cells. Prefer AsyncImage for list-heavy screens.
Prefer one reusable pattern for avatar/card/list images:
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(imageUrl)
.crossfade(true)
.build(),
placeholder = painterResource(Res.drawable.placeholder),
error = painterResource(Res.drawable.image_error),
fallback = painterResource(Res.drawable.image_fallback),
contentDescription = title, // null only for decorative images
contentScale = ContentScale.Crop,
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
)
For accessibility, provide contentDescription unless the image is purely decorative.
Create one shared ImageLoader per app process. Multiple loaders fragment memory/disk caches and reduce hit rates.
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
.crossfade(true)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02)
.build()
}
.build()
}
For libraries, prefer coil-core and pass your own ImageLoader instead of overriding the app singleton.
Coil's pipeline is extensible and executes in this order:
InterceptorMapperKeyerFetcherDecoderRegister custom components once when building ImageLoader:
val imageLoader = ImageLoader.Builder(context)
.components {
add(CustomCacheInterceptor())
add(ItemMapper())
add(ItemKeyer())
add(PartialUrlFetcher.Factory())
add(SvgDecoder.Factory())
}
.build()
| Need | Customize | Why |
|---|---|---|
| Add request retry/short-circuit/global policy | Interceptor |
Wraps entire pipeline; can modify/proceed/return early. Cross-cutting: timeouts, retries, custom cache layer, metrics. |
Accept custom model type in .data(...) |
Mapper |
Normalizes domain data to a supported type (for example ProductImage → URL string). |
| Keep custom data memory-cacheable | Keyer |
Stable memory cache key segment for custom models. If a custom Fetcher introduces a new data type, add a matching Keyer so memory caching works. |
| Support custom source/protocol | Fetcher.Factory<T> |
Data transport: custom scheme, signed URLs, alternate client. |
| Decode custom encoded data/format | Decoder.Factory |
Converts fetched source to a renderable image. |
| Add auth headers for all image requests | Network fetcher + client interceptor | Centralized networking behavior. |
| Per-request dynamic headers | ImageRequest.httpHeaders(...) |
Scoped request-level networking metadata. |
commonMain; OkHttp/Android-only client setup in platform source sets; prefer Ktor network for broad CMP.ImageLoader configuration per app entry point.| Anti-pattern | Problem |
|---|---|
Registering pipeline components per screen/composable; duplicating what request options already cover (httpHeaders, cache policy, size resolver) |
Fragments caches; redundant complexity |
Custom Fetcher without a stable Keyer; volatile data (timestamps, random values) in cache keys |
Poor memory cache hit rate |
Heavy blocking work in Interceptor without bounds/timeouts; platform-only types in commonMain pipeline contracts |
Jank; wrong layering for CMP |
For HTTP cache semantics with OkHttp, register CacheControlCacheStrategy with the network fetcher when you need response Cache-Control behavior.
Default request cache policies are enabled; override memoryCachePolicy / diskCachePolicy / networkCachePolicy only when you need non-default behavior.
Use stable keys when the same logical image appears in multiple places (list → detail, shared element).
ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.memoryCacheKey("image-$id")
.placeholderMemoryCacheKey("image-$id")
.build()
placeholderMemoryCacheKey helps avoid visual flashes by reusing an in-memory result as the placeholder for the next request.
Use .transformations(...) (for example RoundedCornersTransformation) only for pixel-level changes to decoded output. Prefer Modifier.clip / shapes for UI-only effects; transformations materialize bitmaps and can collapse animated images to one frame.
implementation("io.coil-kt.coil3:coil-svg:<version>")
Coil auto-detects and decodes SVGs after this dependency is on the classpath. Register SvgDecoder.Factory() explicitly only if you need non-default wiring.
To load images from Compose Multiplatform resources with Coil, use Res.getUri(...):
AsyncImage(
model = Res.getUri("drawable/sample.jpg"),
contentDescription = null,
)
Use string URIs from Res.getUri. Direct compile-safe handles like Res.drawable.someImage are not currently passed directly as Coil models.
AsyncImage in list cells.LazyColumn/LazyGrid) and stable cache keys (memoryCacheKey) together.rememberAsyncImagePainter, provide a size resolver (rememberConstraintsSizeResolver) to avoid always loading original size.LocalAsyncImagePreviewHandler to inject deterministic preview images.DebugLogger only in debug builds when diagnosing request/decoder/cache behavior.ImageLoader instead of relying on global singleton state.