Four bytes a pixel is the number that decides whether your app gets killed

A bitmap costs width × height × 4 bytes in memory regardless of its file size. Decoding at display size, choosing the right config and budgeting the cache are what keep an image-heavy app from being reclaimed.

6 min read
androidcomposekotlingraphicsperformance

Day 60 — Four bytes a pixel is the number that matters

Day 60 of 100, closing the graphics pillar. One piece of arithmetic explains most image-related crashes, and it isn't the file size.

The symptom

A gallery that gets killed in the background:

LazyVerticalGrid(columns = GridCells.Adaptive(120.dp)) {
    items(photos, key = { it.id }) { photo ->
        AsyncImage(
            model = photo.originalUrl,          // 4000 × 3000
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier.aspectRatio(1f),
        )
    }
}

It scrolls fine on the test device. On a mid-range phone it stutters, and after switching apps a few times the process is reclaimed — so returning to the app shows a cold start rather than the grid.

The photos are 400KB JPEGs. The problem isn't the download.

Why the obvious fix fails

The obvious fix is a bigger cache:

ImageLoader.Builder(context)
    .memoryCache { MemoryCache.Builder(context).maxSizePercent(0.5).build() }
    .build()

Giving the cache half the heap makes the stutter less frequent and the process reclamation more likely, because a large resident heap is exactly what the system looks for when it needs memory.

You've made the app hold more of the wrong thing.

The actual mechanism

A decoded bitmap costs width × height × bytesPerPixel, and the file size is irrelevant.

A 4000×3000 JPEG is maybe 400KB on disk. Decoded at full resolution as ARGB_8888 — four bytes per pixel — it is:

4000 × 3000 × 4 = 48,000,000 bytes ≈ 48 MB

Forty-eight megabytes, to fill a 120dp square. Twenty of them in a scrolling grid is approaching a gigabyte, on a device whose per-app heap may be 256MB.

Compression ratio is a property of the file, not of the memory. That single sentence explains the crash reports.

The fix is to decode at the size you'll draw:

AsyncImage(
    model = ImageRequest.Builder(context)
        .data(photo.originalUrl)
        .size(360, 360)               // decode target, in pixels
        .build(),
    …
)

360 × 360 × 4 is 518KB — ninety times less. Coil infers this from the composable's constraints automatically in most cases, which is the main reason to use a loader rather than decoding by hand.

Ask the server first

Decoding smaller saves memory and still downloads the full file. A CDN that accepts a size parameter saves both:

.data("${photo.baseUrl}?w=360&h=360&fit=crop")

Bandwidth, decode time and heap all drop together. For a feed, this is usually the single highest-impact image change available, and it's a backend conversation rather than a client one — which is why it tends not to happen.

Bit depth

ARGB_8888 is four bytes per pixel and the default. Two alternatives matter:

RGB_565 — two bytes per pixel, no alpha, 16-bit colour. Half the memory, visible banding on gradients, and no transparency. Reasonable for photographic thumbnails; poor for anything with smooth gradients or transparency.

HARDWARE — the bitmap lives in graphics memory rather than the Java heap. It doesn't count against your heap limit at all, which is a large win for a gallery. The constraint is that you can't read its pixels, so any code doing per-pixel work must opt out.

ImageRequest.Builder(context)
    .data(url)
    .bitmapConfig(Bitmap.Config.RGB_565)      // thumbnails only
    .allowHardware(true)                       // default; disable only if you read pixels
    .build()

Hardware bitmaps are the default in modern loaders, and the usual reason they get disabled is a palette extraction or blur that needs pixel access. Doing that on the thumbnail rather than the full image keeps the win.

Formats, briefly

The encoded side matters less than the decoded side, and it isn't nothing:

WebP is the sensible default for app-bundled raster assets — meaningfully smaller than PNG at the same quality, with alpha support, and universally available.

AVIF is smaller again and decodes more slowly, which is a reasonable trade for downloaded content and a poor one for an asset on the startup path.

JPEG remains right for photographs whose source is already JPEG, where re-encoding only loses quality.

None of these changes the four-bytes-per-pixel arithmetic. They change how long the download takes and how much space the bundle occupies — real wins, addressing a different problem from the one that gets the process killed.

Budget the cache deliberately

Two caches, with different jobs:

ImageLoader.Builder(context)
    .memoryCache {
        MemoryCache.Builder(context).maxSizePercent(0.25).build()    // decoded, fast
    }
    .diskCache {
        DiskCache.Builder()
            .directory(context.cacheDir.resolve("image_cache"))
            .maxSizeBytes(100L * 1024 * 1024)                        // encoded, survives restart
            .build()
    }
    .build()

Memory cache holds decoded bitmaps — fast, expensive. Disk cache holds the encoded bytes — slower, cheap, and survives a process death, which is what makes a return to the app feel instant rather than blank.

25% of available memory is a reasonable default. The instinct to raise it is usually better spent on decoding smaller, since a cache of correctly-sized bitmaps holds far more entries than the same bytes of oversized ones.

The device you develop on hides this

Worth stating plainly, because it's why these bugs ship. A recent flagship has a large heap, fast storage and a fast network. Every symptom above — stutter, reclamation, slow first paint — is proportionally smaller there.

The cheap approximations:

  • Developer Options → Background process limit → "No background processes" makes reclamation immediate and obvious.
  • An emulator with 2GB RAM approximates a low-end device's heap pressure.
  • Network throttling surfaces the missing placeholders from Day 55.

None is a substitute for a real low-end device, and all three find problems the flagship never shows.

How to prove it

The arithmetic is the proof, and it's worth putting in a test so an asset change can't silently break it:

@Test fun thumbnailsDecodeSmall() {
    val request = thumbnailRequest(photo)
    val size = request.sizeResolver.size()
    assertTrue("decode target too large: $size", size.width <= 512 && size.height <= 512)
}

At runtime, the memory profiler's allocation view attributes bitmap memory directly. Scroll a grid and watch the total — if it climbs without settling, the cache is unbounded or the decode size isn't being applied.

adb shell dumpsys meminfo <package> gives the same number without Studio attached, and its Graphics line is where hardware bitmaps show up. Comparing that line before and after a scroll through the whole gallery is the fastest read on whether the cache is bounded.

What this generalizes to

The graphics pillar's closing point: a representation's cost is not its file size. A 400KB JPEG is 48MB of pixels; a 2KB vector is a few hundred path operations per frame; a gradient is a shader compiled once. In each case the number that matters is the one at the point of use, not the one in the build output.

Six days of graphics keep landing on the same discipline — know what the thing costs where it runs, and ask the system for the size rather than assuming one. It is the same lesson the adaptive pillar taught about windows and the theming pillar taught about colour: the value you need is available at the point of use, and substituting a constant you measured elsewhere is where the bug enters. Day 55's aspectRatio, Day 57's intrinsicSize, Day 58's infinity sentinel and today's decode target are four faces of that one habit.

Tomorrow, Day 61 opens the animation pillar.


Day 60 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Optimize images.