Engineering

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.

MobileByteSensei 9 min read
kmpcomposearchitecture

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.

Five stages of an image pipeline with the platform-specific middle: fetch and draw portable at the ends, while decode with downsampling, the memory cache sized in bytes and subject to platform pressure signals, and the disk cache differ per target — and the decode-size decision the call site knows is never passed down, so a 2400 by 1600 source becomes a 15 megabyte bitmap for a 120dp thumbnail

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.

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".

Follow by RSS