A vector costs CPU once, a bitmap costs memory forever
ImageVector is drawing commands executed at render time; ImageBitmap is pixels held in memory. The choice is about scaling fidelity versus per-frame cost, and the common mistakes run in both directions.

Day 56 of 100. Two ways to represent an image, and the difference is not a file format detail — it changes what happens on every frame.
The symptom
An app whose icons are blurry on a tablet:
Icon(
painter = painterResource(R.drawable.ic_settings), // a 24×24 PNG
contentDescription = "Settings",
modifier = Modifier.size(64.dp),
)
The PNG was exported at 24dp because that's the icon size in the design. Rendered at 64dp it's upscaled, and every edge is soft.
The mirror-image problem, in the same codebase: a full-screen illustration shipped as a vector, which drops frames while a screen animates in.
Why the obvious fix fails
The obvious fix for the icon is more PNGs — mdpi through xxxhdpi, plus a larger export
for the tablet layout. That's five files per icon, a rebuild every time the design changes,
and it still breaks at a size nobody exported.
The obvious fix for the illustration is to simplify the vector. That helps, and it's fighting the representation rather than choosing a different one.

The actual mechanism
ImageVector is a tree of drawing commands — paths, fills, strokes, groups. Nothing is
rasterised until it's drawn, so it renders at any size at full fidelity. The cost is CPU:
those paths are executed to produce pixels, and complexity scales that cost.
ImageBitmap is a pixel buffer. Drawing it is a memory copy — very fast, constant time
regardless of complexity. The cost is memory: width × height × 4 bytes, resident for as
long as you hold it, and scaling it up interpolates.
So the trade is:
| ImageVector | ImageBitmap | |
|---|---|---|
| Scaling | perfect at any size | interpolates; blurs when upscaled |
| Memory | small (the commands) | w × h × 4 bytes |
| Draw cost | proportional to path complexity | constant, very low |
| Best for | icons, logos, simple illustration | photos, complex art, anything painterly |
| Tinting | free (tint on Icon) |
requires a colour filter |
The icon should be a vector. The illustration should be a bitmap — a WebP, sized for the largest place it appears.
Caching the vector
The mechanism matters for a subtle reason: painterResource on a vector parses the XML
the first time, which is not free.
Inside a LazyColumn item, that parse can happen per item:
items(rows) { row ->
Icon(painterResource(R.drawable.ic_chevron), null) // parsed per item
}
Compose caches resolved resources, so this is usually fine — but for a vector used in every row of a long list, hoisting it makes the intent explicit and removes any doubt:
val chevron = painterResource(R.drawable.ic_chevron)
LazyColumn {
items(rows) { row -> Icon(chevron, null) }
}
The ImageVector.Builder API also lets you define icons in Kotlin rather than XML, which
is what the Icons.Default.* set is — no resource lookup, no parse, and it works
unchanged in Compose Multiplatform where Android resources don't exist.
Icon versus Image
Two composables that both draw a painter, with one important difference:
Icon(painter, contentDescription = "Settings") // TINTS with LocalContentColor
Image(painter, contentDescription = "Photo") // draws as-is
Icon applies the current content colour, which is why an icon inside a Button
automatically matches the label. That's Day 44's LocalContentColor doing its job, and
it's why a multi-colour logo passed to Icon comes out as a flat silhouette — a real
confusion that has a one-word fix.
Icon also defaults to 24dp, matching the Material icon size.
The vector's own size
A vector has an intrinsic size from its viewportWidth/viewportHeight, and it will use
that if the modifier doesn't constrain it. An icon authored at a 48×48 viewport drops into
a layout at 48dp, not 24dp — which looks like a Compose bug and is an asset property.
Worth checking when an icon set renders inconsistently: the viewport dimensions in the drawable XML, not the code.
What vectors can't do
Two limits worth knowing before committing an asset to vector format.
Gradients are supported; filters mostly aren't. Android's vector drawable format covers paths, groups, clip paths and gradient fills. Blurs, blend modes and the more elaborate effects an SVG can express get dropped at import — which is why an illustration that looks right in the design tool sometimes imports flat.
Complexity has a real cost. A vector with several hundred path nodes, re-executed every frame while a screen animates, is measurably slower than blitting a bitmap. The threshold is fuzzy, but "an illustration a designer drew freehand" is usually past it, and "an icon" is usually nowhere near.
The practical division that holds: if a human could plausibly redraw it with a dozen shapes, it's a vector. If it has texture, photography or painterly shading, it's a bitmap.
Converting between them
Occasionally you need pixels from a vector — a shortcut icon, a notification, a share sheet:
val painter = painterResource(R.drawable.ic_logo)
val bitmap = remember(painter) {
ImageBitmap(200, 200).also { image ->
CanvasDrawScope().draw(density, LayoutDirection.Ltr, Canvas(image), Size(200f, 200f)) {
with(painter) { draw(size) }
}
}
}
Rasterise once at the size you need and remember it. Doing this per frame is the
worst of both representations — the vector's draw cost plus the bitmap's allocation.
How to prove it
The scaling claim is a preview at two sizes:
@Preview
@Composable fun IconAtSizes() = Row(verticalAlignment = Alignment.CenterVertically) {
Icon(painterResource(R.drawable.ic_settings), null, Modifier.size(24.dp))
Icon(painterResource(R.drawable.ic_settings), null, Modifier.size(96.dp))
}
A vector is crisp at both. A raster is visibly soft at 96dp, which makes the asset type obvious without opening the file.
For the memory claim, the Android Studio memory profiler shows bitmap allocations
directly. A full-screen image at 1080×2400 is about 10MB as ARGB_8888 — worth knowing
before you cache several of them.
What this generalizes to
The principle is a representation encodes a trade, and the right one depends on how the asset is used. Commands are compact and cost time to execute; pixels are bulky and cost nothing to copy. Neither is better; they're opposite ends of a space-time trade that shows up everywhere from fonts to video codecs.
The useful habit is asking what varies. If the size varies, you want commands. If the content is complex and the size is known, you want pixels. Most asset mistakes come from picking by file format habit rather than by that question.
There is a third option worth knowing about for the middle ground: rasterise the commands once at the size you need and cache the result. That's what font rendering does with a glyph atlas, and what the vector-to-bitmap conversion above is doing by hand. When an asset is complex and drawn repeatedly at one size, it's the best of both — and it's only available because the two representations are convertible.
Tomorrow, Day 57: writing a custom Painter, and where it sits between an asset and a
Canvas.
Day 56 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: ImageVector.