Lazy Lists in Compose: Keys, contentType, and the Jank You Can't Profile Away
A LazyColumn without keys identifies items by position, so inserting at the top invalidates everything. Add contentType for mixed lists so compositions can be reused. The two parameters that fix most list jank, and why the profiler won't point at them.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Without key, a lazy list identifies items by index, so prepending one item makes
every subsequent item look new: state is lost, animations break, everything recomposes. Without
contentType, a list with mixed row shapes can't reuse compositions across items. Both are one
parameter each, both are invisible in a profiler, and together they fix most list jank.
The list that looks fine
LazyColumn {
items(messages) { message ->
MessageRow(message)
}
}
Correct, idiomatic, and carrying two hidden costs.
Cost 1: no key means identity by position
Compose needs to know which item is which across recompositions. Without a key, it uses the index.
So when a new message arrives at the top:
- The item at index 0 was message A, now it's message B.
- Index 1 was B, now it's C.
- Every index now maps to a different message.
From Compose's perspective, nothing was inserted — everything changed. The consequences:
- State is lost. A
rememberinside a row (an expanded flag, an animation, a text field) belongs to the slot, not the item. Row state now attaches to the wrong message. - Animations are wrong.
animateItemPlacement()has no way to know an item moved rather than changed. - Everything recomposes, because every slot received different data.
In a chat app that prepends, or a feed with pull-to-refresh, this is a permanent tax paid on every update.
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(message)
}
}
With a key, identity follows the item. Insert at the top and the other 200 rows keep their composition, their state and their position — Compose only creates the one new row.
Cost 2: no contentType means no reuse across shapes
Lazy lists reuse compositions the way RecyclerView reuses view holders. When a row scrolls off,
its composition can be reused for a row scrolling on — but only if the new row has the same structure.
If your list mixes shapes, Compose can't know which are compatible:
LazyColumn {
items(
items = feed,
key = { it.id },
contentType = { it::class }, // header, text, image → distinct reuse pools
) { item ->
when (item) {
is Header -> HeaderRow(item)
is TextMessage -> TextRow(item)
is ImageMessage -> ImageRow(item)
}
}
}
Now Compose maintains a reuse pool per content type, and an image row scrolling in reuses another image row's composition rather than building from scratch. On heterogeneous lists this is a substantial scroll-performance win.
For a homogeneous list, contentType changes nothing — don't add it out of habit.
Two mistakes that undo the fix
Unstable or colliding keys.
key = { it.hashCode() } // collisions crash at runtime
key = { UUID.randomUUID() } // new key every composition — worse than no key
Keys must be stable across recompositions and unique within the list. A database id or a
server id is right. A hash is not — collisions are rare enough to reach production and then crash
with IllegalArgumentException: Key was already used.
A list whose identity changes every recomposition.
// New list instance every pass — keys can't save you
LazyColumn {
items(messages.filter { it.visible }, key = { it.id }) { ... }
}
The filtering itself isn't the problem; recreating derived state every recomposition is. Hoist it:
val visible = remember(messages) { messages.filter { it.visible } }
Why the profiler doesn't show it
This is the frustrating part. There's no single slow method — the cost is spread thinly across many recompositions that each look reasonable. A trace shows the main thread busy during scroll with no obvious culprit, which is exactly the pattern that sends people optimising the wrong thing.
Use the tool built for this instead. In Layout Inspector, enable recomposition counts and scroll: if rows that stayed on screen have climbing counts, you have a key problem. That signal is unambiguous in a way a flame chart isn't.
The mental model
A lazy list is a recycler, and
keyis what makes recycling correct.
Once you hold that, both parameters stop being optimisations you might add and become part of writing
the list properly. key on every lazy list with a stable id; contentType whenever the rows aren't
all the same shape.
Two parameters, most of your list jank.