Load Images with Coil AsyncImage in Jetpack Compose

Quick answer: use Coil’s
AsyncImagefor most remote images in Compose. Give it an image URL orImageRequest, an accuratecontentDescriptionfor meaningful images, intentionalContentScale, and placeholder/error UI.AsyncImageresolves the load size from its Compose constraints, which makes it the right default for cards and lazy lists.
Add Coil 3 Compose support
For an Android Compose app, the current Coil documentation shows these dependencies:
dependencies {
implementation("io.coil-kt.coil3:coil-compose:3.6.2")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.6.2")
}coil-compose supplies Compose APIs, while coil-network-okhttp enables network fetching on Android. Coil’s getting-started guide lists the artifacts and notes that Compose Multiplatform should use a Ktor network artifact instead of OkHttp. Keep both artifacts on the same Coil version or use Coil’s BOM.
Start with AsyncImage
AsyncImage runs the request asynchronously and renders the result. It supports the normal Image layout arguments as well as placeholder, error, fallback, and request callbacks.
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
AsyncImage(
model = photoUrl,
contentDescription = "Sunset over a mountain lake",
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
)The Coil Compose guide recommends AsyncImage in most cases because it detects the composable’s constraints and ContentScale to choose a suitable image size. That avoids accidentally loading the original full-resolution image for a small thumbnail.
Use contentDescription = null only when the image is genuinely decorative. Product photography, an avatar that identifies a person, or a meaningful article image needs a useful description—or adjacent text that supplies the same information.
Build a deliberate request
Use ImageRequest when the request needs options such as crossfade, transformations, headers, caching policy, or an explicit data source.
import android.content.Context
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
fun articleImageRequest(
context: Context,
imageUrl: String,
): ImageRequest = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build()
AsyncImage(
model = articleImageRequest(LocalContext.current, article.imageUrl),
contentDescription = article.imageDescription,
contentScale = ContentScale.Crop,
)For a list, build models from immutable UI state and keep them stable between recompositions. A changing URL naturally triggers a new request; unrelated row state should not rebuild an equivalent request every frame.
Give loading and failure a design
An image request can load slowly, fail, or receive null data. Provide a visual fallback that preserves the card’s shape and prevents content from jumping.
import androidx.compose.ui.res.painterResource
import coil3.compose.AsyncImage
AsyncImage(
model = imageUrl,
contentDescription = title,
placeholder = painterResource(R.drawable.image_placeholder),
error = painterResource(R.drawable.image_error),
fallback = painterResource(R.drawable.image_missing),
contentScale = ContentScale.Crop,
)placeholderis drawn while the request is loading.erroris drawn after a failed request.fallbackis drawn when the model is null; it defaults to the error painter.
Avoid using a spinner for every small feed thumbnail. A fixed-shape placeholder often reads more calmly and avoids layout shifts. If retry is user-meaningful, expose a visible retry action rather than relying on an invisible automatic loop.
Shape, crop, and modifier order
ContentScale.Crop fills the destination while preserving aspect ratio, which can cut off content. ContentScale.Fit shows the full image but may leave empty space. Choose from the meaning of the image, not merely its look.
For a rounded image, clip the composable before the image is rendered:
AsyncImage(
model = imageUrl,
contentDescription = title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(16.dp)),
)The image has a measured box before Coil can choose the request size. This is one reason AsyncImage works well for ordinary Compose layouts. For more on what modifier placement changes, see Why Modifier Order Matters.
Use painter and subcomposition only when necessary
rememberAsyncImagePainter is useful when an API needs a Painter or when you must directly observe AsyncImagePainter.state. Its default behavior does not resolve the on-screen size, so provide rememberConstraintsSizeResolver when request sizing matters.
SubcomposeAsyncImage offers slots for loading, success, and error content. It is occasionally useful when the first frame’s state must be immediately current, but Coil warns that subcomposition is slower and should be avoided in performance-critical paths such as LazyColumn and LazyRow.
For a standard feed row, prefer AsyncImage with a placeholder/error painter. If a custom state UI is truly necessary, observe a painter state deliberately and profile the list.
Images in LazyColumn
In a lazy list, stable row keys and properly constrained images are both important:
LazyColumn {
items(articles, key = { it.id }) { article ->
AsyncImage(
model = article.imageUrl,
contentDescription = article.imageDescription,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp),
)
}
}Use the model’s durable ID for the lazy-list key and let each image receive real bounds. Lazy list keys explains why identity matters when rows move or data refreshes. Do not replace AsyncImage with SubcomposeAsyncImage per row merely to draw a spinner.
Previews and tests
Network access is disabled in Android Studio’s preview environment, so network URLs fail by default. Coil provides LocalAsyncImagePreviewHandler for a controlled preview result, and the same approach helps Compose preview screenshot testing. Test success, loading, error, null-model, narrow-width, and slow-network designs—not only the happy-path image.
Common mistakes
Using a vague content description
“Image” does not tell a screen-reader user what the image contributes. Describe meaningful content, or use null for decoration.
Loading original-size images through a painter
rememberAsyncImagePainter defaults to original size unless you provide a resolver. Prefer AsyncImage unless a painter is genuinely required.
Making image failure invisible
An empty rectangle can look like a broken layout. Give error and fallback states a consistent visual treatment and a retry path where it matters.
Putting subcomposition in every lazy-list row
Subcomposition has real overhead. Use AsyncImage for ordinary cells and reserve slot-based UI for cases that justify it.
FAQ
Is AsyncImage the same as Image?
Image renders a painter you already have. AsyncImage executes a Coil request asynchronously, then renders its painter with image-loading states.
Does AsyncImage cache images?
Coil’s ImageLoader fetches, decodes, caches, and returns image results. Configure a custom loader only when your app has a concrete caching, networking, or dependency-injection requirement.
Should every image crossfade?
No. A crossfade can be pleasant for a larger editorial image, but it can make a dense scrolling list feel busy. Enable it where the transition improves comprehension.
Summary
Use AsyncImage as the default Coil Compose API, constrain the image in layout, choose an intentional crop policy, and design loading and failure as part of the component. Move to a painter or subcomposition only when their extra control solves a real UI requirement.