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.
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 case this post is about
One photo grid, twenty 120dp cells visible, each loading a 4000×3000 source:
LazyVerticalGrid(columns = GridCells.Adaptive(120.dp)) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.originalUrl, // 4000 × 3000
contentDescription = null,
contentScale = ContentScale.Crop,
Nothing there is wrong, and the app is killed in the background on a mid-range device. The arithmetic is the whole post:
4000 × 3000 × 4 = 48,000,000 bytes ≈ 48 MB
Forty-eight megabytes to fill a 120dp square, twenty of them on screen, against a per-app heap that may be 256MB. The JPEG on disk is 400KB, which is the number everybody looks at and the number that has nothing to do with it. This post follows this grid, because the device you develop on has the headroom to hide all of it.
The symptom: a gallery that gets killed in the background
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 a bigger cache makes the problem worse
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 mechanism: a decoded bitmap costs width × height × bytes per pixel
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 when you let it, and the arithmetic is worth having in front of you because the budget is what decides whether a grid scrolls or dies:
enum class Config(val bytesPerPixel: Int) { ARGB_8888(4), RGB_565(2), ALPHA_8(1) }
data class Decode(val srcW: Int, val srcH: Int, val targetPx: Int, val config: Config)
fun bytes(d: Decode): Long {
// Decoding scales to FIT the target, preserving the source ratio — so a non-square
// source costs more than target x target, which is the step people drop.
val scale = minOf(d.targetPx.toFloat() / d.srcW, d.targetPx.toFloat() / d.srcH)
val w = (d.srcW * scale).toInt().coerceAtLeast(1)
val h = (d.srcH * scale).toInt().coerceAtLeast(1)
return w.toLong() * h * d.config.bytesPerPixel
}
fun report(label: String, d: Decode, visible: Int, heapMb: Int) {
val one = bytes(d)
val total = one * visible
val pct = total * 100.0 / (heapMb * 1024L * 1024L)
println("%-26s %7.1f KB each x%2d = %7.2f MB (%.1f%% of a %dMB heap)%s"
.format(label, one / 1024.0, visible, total / 1048576.0, pct, heapMb,
if (pct > 50) " <- OOM territory" else ""))
}
The photo grid, one source image, four decode strategies, twenty cells visible:
val src = 4000 to 3000
listOf(
"full res, ARGB_8888" to Decode(src.first, src.second, 4000, Config.ARGB_8888),
"360px, ARGB_8888" to Decode(src.first, src.second, 360, Config.ARGB_8888),
"360px, RGB_565" to Decode(src.first, src.second, 360, Config.RGB_565),
"120dp @3x = 360px" to Decode(src.first, src.second, 360, Config.ARGB_8888),
).forEach { (label, d) -> report(label, d, visible = 20, heapMb = 256) }
full res, ARGB_8888 46875.0 KB each x20 = 915.53 MB (357.6% of a 256MB heap) <- OOM territory
360px, ARGB_8888 379.7 KB each x20 = 7.42 MB (2.9% of a 256MB heap)
360px, RGB_565 189.8 KB each x20 = 3.71 MB (1.4% of a 256MB heap)
120dp @3x = 360px 379.7 KB each x20 = 7.42 MB (2.9% of a 256MB heap)
Two things the arithmetic shows that the prose rounds off. The 360px decode is 380KB, not the 518KB a
square would cost — the 4:3 source fits to 360×270, so the saving is slightly better than advertised.
And RGB_565 halves it again for a photo thumbnail with no alpha, which is the cheapest remaining
win and the one that gets skipped because two bytes per pixel sounds like a quality decision rather
than a 3.7MB one.
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: putting the arithmetic in a test
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: cost is not file size
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.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".