5 min read

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.

AndroidJetpack ComposePerformanceLazyColumnRecomposition
HR

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 remember inside 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 key is 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.

Keep reading