Image loading is the least portable thing in your shared UI
Decode, cache and memory pressure are platform concerns, and the composable that hides them decides your scroll performance.
TL;DR
AsyncImage(url, contentDescription) is one line in shared code and five platform decisions
underneath: where the bytes come from, how they are decoded, at what resolution, into which cache,
and what happens when memory runs low.
A shared composable that exposes none of those gives you a list that scrolls smoothly on a desktop and stutters on a mid-range phone — loading full-resolution bitmaps for 120dp thumbnails and holding them all.
The fix is not to abandon the shared composable. It is to make target size and cache policy part of its API, because those are the two decisions the call site knows and the library cannot guess.
The case this post is about
A list of articles with thumbnails:
// commonMain
@Composable
fun ArticleRow(article: Article) {
Row {
AsyncImage(
model = article.imageUrl, // a 2400×1600 JPEG from a CMS
contentDescription = null,
modifier = Modifier.size(120.dp),
)
Column { Text(article.title); Text(article.excerpt) }
}
}
Four lines, shared, and it renders correctly everywhere. The problem is entirely non-functional, which is why it reaches production.
The symptom
Four measurements.
Jank on Android, proportional to image size. A 60-item list:
Macrobenchmark, scroll 60 items
frame duration P50 8.1ms
frame duration P95 74.3ms ← 4.5 dropped frames per jank event
jank frames 11.2%
Memory climbing with scroll depth. Each 2400×1600 image decoded to a full bitmap:
2400 × 1600 × 4 bytes = 15.4 MB per image, in memory
scroll 20 items → ~300 MB of bitmap
An OOM on a 2GB device, reported as "app crashes when scrolling".
wasm downloading 40 MB for a list view. No resizing server-side, no cache between page loads:
list view, cold: 2.4 MB × 17 images = 41 MB transferred
And iOS behaving fine, which delayed the diagnosis by a week — more memory headroom and a different decode path meant the one platform the team demoed on looked good.
Why the obvious fix failed
"Add .size(120.dp) — it's already there." The modifier constrains layout, not decode.
The image is decoded at its intrinsic size and then drawn scaled. Layout size and sample size are
different things, and conflating them is the central mistake here.
"Lower the image quality on the server." Correct and insufficient: one size cannot serve a 120dp thumbnail and a full-screen hero. You need per-use-case sizes, which means the call site has to say which it wants.
"Set a global memory cache limit." Caps the damage and does not stop a single 15 MB decode from spiking. It also made the list slower, because a small cache evicted images that were about to be scrolled back to.
"Use a different image library." The libraries are good and will all decode at intrinsic size if you do not tell them otherwise. The missing information is in my call site, not in their implementation.
The actual mechanism
Five stages, and three of them are platform-specific in ways the shared API hides.
Fetch. An HTTP call — portable, and subject to the platform's connection behaviour.
Decode. Bytes to a bitmap. This is where the cost is, and it is entirely platform: Android has
BitmapFactory with inSampleSize, Apple platforms have ImageIO with thumbnail options, a browser
decodes via the image element or createImageBitmap. Each supports downsampling during decode,
which is dramatically cheaper than decode-then-scale.
Memory cache. Decoded bitmaps, sized in bytes, subject to platform memory pressure signals —
onTrimMemory on Android, memory warnings on iOS, nothing in a browser.
Disk cache. Android and iOS have real storage. A browser has the HTTP cache and whatever you build on top.
Draw. Portable.
The stage that punishes a shared configuration hardest is the memory cache, because the natural way to configure it is a number — and a number is a different decision on each target:
// commonMain — one figure, four very different meanings
ImageLoader { memoryCache { maxSizeBytes(128 * 1024 * 1024) } }
128 MB is unremarkable on a desktop, roughly a quarter of the heap on a mid-range Android device,
and on wasm it is bounded by a tab allocation with no pressure signal to tell you when you have
gone too far. The Android and iOS cases at least get told: onTrimMemory and memory warnings
let a cache evict before the OS intervenes. A browser's first notification is the tab dying.
So the portable form is a fraction with a per-platform ceiling, not a constant:
expect fun memoryCacheBudgetBytes(): Long
// androidMain — a share of what this device actually gave us
actual fun memoryCacheBudgetBytes() =
(Runtime.getRuntime().maxMemory() * 0.15).toLong()
// wasmJsMain — no pressure signal, so bound it conservatively and by COUNT as well
actual fun memoryCacheBudgetBytes() = 32L * 1024 * 1024
Which is the same shape as the decode stage one row up. The portable thing is the policy — "use a modest share of available memory" — and the number that policy produces is platform data. A shared constant looks like consistency and is four different risk postures wearing one value.
So the decision that matters — decode at what size — happens in the stage that is most platform-specific, and the shared composable was not being told the answer.

The library knows how to decode. Only the call site knows the display size and whether the image is worth caching. A shared API that does not carry those two facts has withheld the only information the platform needed.
The fix
Make size and cache policy explicit in the shared API, and resize at the source where you can.
// commonMain — the shared surface carries the two decisions the call site owns
enum class ImagePurpose { Thumbnail, Card, Hero }
@Composable
fun AppImage(
url: String,
purpose: ImagePurpose,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val targetPx = with(density) { purpose.dp.roundToPx() }
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(resized(url, targetPx)) // server-side resize when supported
.size(targetPx) // DECODE size, not layout size
.memoryCachePolicy(purpose.memoryPolicy)
.diskCachePolicy(purpose.diskPolicy)
.crossfade(true)
.build(),
contentDescription = contentDescription,
modifier = modifier,
)
}
private val ImagePurpose.dp get() = when (this) {
Thumbnail -> 120.dp
Card -> 320.dp
Hero -> 1080.dp
}
Four decisions:
.size(targetPx) is the one that fixes the jank and the OOM. It instructs the decoder to
downsample during decode. A 120dp thumbnail at 3× density decodes to 360×360 — about 0.5 MB instead
of 15.4 MB, a factor of 30.
ImagePurpose rather than a raw size parameter. A call site passing arbitrary pixel values
drifts; three named purposes keep the sizes consistent and reviewable, and they map onto the
server's available renditions.
Server-side resize where the CDN supports it, which is the only thing that fixes wasm's transfer volume — decode-size does nothing about bytes on the wire:
// commonMain
private fun resized(url: String, px: Int): String =
if (url.startsWith(CDN_BASE)) "$url?w=$px&fm=webp" else url
And cache policy per purpose, because a hero image and a thumbnail have different value:
private val ImagePurpose.memoryPolicy get() = when (this) {
Thumbnail, Card -> CachePolicy.ENABLED
Hero -> CachePolicy.DISABLED // large, rarely re-shown
}
The platform loader stays a platform concern, configured once at startup with the one number only the platform knows:
// commonMain
expect fun imageLoaderConfig(): ImageLoaderConfig
// androidMain — a FRACTION of available memory, read from the platform
actual fun imageLoaderConfig() = ImageLoaderConfig(
memoryFractionOfAvailable = 0.20,
respectTrimMemory = true,
)
// wasmJsMain — no disk cache, no trim signal; keep the memory cache small
actual fun imageLoaderConfig() = ImageLoaderConfig(
memoryBytes = 32 * 1024 * 1024,
diskEnabled = false,
)
A fraction of available memory rather than a fixed byte count is the detail worth copying: a fixed cache size is wrong on both a 2GB phone and a 16GB desktop.
How we proved it
Frame timing, measured rather than felt:
Macrobenchmark, scroll 60 items, Pixel 6a
before after
frame duration P50 8.1ms 6.9ms
frame duration P95 74.3ms 9.4ms
jank frames 11.2% 0.3%
P95 is the number that matters — P50 was always fine, which is why the problem was invisible on a dev machine.
Memory, from the actual bitmap allocation:
scroll 20 items, Android heap dump
before: 312 MB in Bitmap instances (20 × ~15.4 MB)
after: 11 MB in Bitmap instances (20 × ~0.5 MB)
Transfer volume on wasm:
list view, cold load
before: 41.2 MB / 17 requests
after: 1.9 MB / 17 requests ← server-side w=360&fm=webp
And a regression test, so the decode size cannot silently revert — which is the part most likely to be undone by a future edit:
// commonTest
@Test
fun everyPurposeRequestsAnExplicitDecodeSize() {
ImagePurpose.entries.forEach { purpose ->
val request = buildRequest(url = "https://cdn/x.jpg", purpose = purpose, density = 3f)
assertNotNull(request.sizeResolver, "no decode size for $purpose")
assertTrue(request.definedWidth!! <= purpose.dp.value * 3 + 1)
}
}
with .size(targetPx) removed: FAILED — no decode size for Thumbnail
Two honest limits. These numbers are from one device and one CDN; the factor-of-30 memory saving is specific to a 2400×1600 source feeding a 120dp slot, and a project whose images are already appropriately sized will see much less. And iOS was never the problem here — I verified the fix did not regress it rather than demonstrating an improvement, because there was nothing to improve.
What this generalizes to
Layout size and decode size are different, and conflating them is a whole class of bug. The same split appears in video (display size versus decode resolution), in fonts (rendered size versus rasterised atlas), and in any pipeline where a resource is prepared before it is drawn. The modifier that positions a thing rarely controls how expensively it was produced.
An abstraction must carry the information only the caller has. Display size and cache value are call-site knowledge. A shared API that omits them forces the library to guess, and the guess is "full fidelity", which is the expensive one.
Name the use case, not the number. ImagePurpose.Thumbnail keeps three sizes consistent across
hundreds of call sites. Raw pixel parameters drift immediately and cannot be audited.
Measure P95, not P50. The median frame time was always acceptable. The problem lived entirely in the tail, which is also where users live. For anything a person perceives as smoothness, the average is the number that hides the bug.
Related
- Wasm has no threads, and that decides your dispatcher — another shared call with a per-platform cost
- There is no File in commonMain, and that is a feature — the disk-cache half of this problem
- Your shared database needs a driver per platform, and that is the easy half — hidden platform properties in a shared signature
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".