Android – Lazy Lists and Keeping Them Fast

August 23, 20269 min readUpdated 10/11/2026

Lists are where most of an app's screen time is spent and where most of its jank comes from. The menu, the order history and the cart are all lists in this app, and each makes a different decision about how to draw one.

This lesson covers the lazy layouts, the two parameters that make them correct — key and contentType — and how Compose decides which rows it can skip, which is the part most performance advice from before 2024 now gets wrong.

Column versus LazyColumn

The home screen is a Column with verticalScroll. That composes, measures and keeps in memory every child, visible or not. For a screen with a hero card and three lines of text that is fine — it is the cheapest possible layout.

For a list of things — products, orders — it is wrong. A LazyColumn composes only the items currently on screen, plus a small prefetch window, and recycles the rest as you scroll. A hundred-item menu costs the same as a ten-item one:

LazyColumn(
    contentPadding = PaddingValues(Spacing.lg),
    verticalArrangement = Arrangement.spacedBy(Spacing.md),
    modifier = Modifier.fillMaxSize(),
) {
    item(contentType = "picker") {
        OptionPicker(
            options = listOf(
                Option(ProductType.PIZZA, "Pizzas"),
                Option(ProductType.DRINK, "Drinks"),
            ),
            selected = section,
            onSelect = { section = it },
        )
    }

    item(contentType = "heading") {
        Text(
            if (section == ProductType.PIZZA) "Pizzas" else "Drinks",
            style = MaterialTheme.typography.titleLarge,
            modifier = Modifier.semantics { heading() },
        )
    }

    /*
     * `key` is the product's id. Without it, LazyColumn identifies rows by POSITION, so
     * switching from pizzas to drinks would reuse the first pizza row's remembered state
     * for the first drink. `contentType` lets the list recycle a product row only into
     * another product row, never into the picker.
     */
    items(products, key = { it.id }, contentType = { "product" }) { product ->
        ProductCard(product, onClick = { onProductClick(product) })
    }
}

The content is not composables but a small DSL. item { } adds one entry; items(list) { } adds one per element. A lazy list can mix both, which is how the menu puts its Pizzas/Drinks picker and a heading above the products in the same scrolling list — rather than a fixed header above a separate list, which would not scroll away.

contentPadding pads the scrolling content, not the list: the first card starts 16dp from the top, but scrolled content passes under that space instead of being clipped by it. Padding the list with a modifier instead clips the content at the padding edge.

Keys

Without a key, a lazy list identifies items by position. Item 0 is "whatever is first". That is fine until the list changes, and then it is a state bug.

On the menu, switching from Pizzas to Drinks replaces the list. Without keys, the first drink row is "item 0" — the same identity the first pizza row had — and inherits anything that row remembered: an expanded state, an animation in progress, a scroll position in a nested row. With key = { it.id } each row's identity is the product it shows, and a new product gets a new, empty slot.

Keys also matter when a list changes in place. The cart's lines can be removed from the middle:

items(cart.items, key = { it.lineId }) { item ->
    CartLineRow(item, onQuantityChange = { onQuantityChange(item.lineId, it) }, onRemove = { onRemove(item.lineId) })
    HorizontalDivider(color = PizzaTheme.colors.borderSubtle)
}

Removing line two with positional identity would hand line three's state to the slot that used to be line two. With the line's own id as its key, Compose knows exactly which item left, keeps the others' state attached to the right rows, and can animate the change with Modifier.animateItem() — which only works on keyed lists.

The order history is the case where a key matters even with no deletions. New orders arrive at the top, so every existing order moves down one position on each refresh:

    items(state.value, key = { it.id }) { order ->
        OrderRow(order, onClick = { onOpenOrder(order.id) })
    }
}

Keyed by order id, a refresh that adds one order composes one new row and moves the rest. Keyed by position, every row would be told its data changed, and every row would recompose.

Keys anchor the scroll position — which can hide a new item

Keys have one consequence that surprised this app's own author. A keyed list keeps its scroll position anchored to the item that was first on screen. Insert an order above it and the list stays put — the new order lands one row above the visible area. When the Orders tab refreshed after an order was placed, the customer saw exactly the list they saw before. The fix keeps them at the top only if they were already there:

val listState = rememberLazyListState()
val newestId = state.value.first().id

/*
 * A keyed lazy list keeps its scroll position anchored to the item that was first on
 * screen. That is usually what you want — and it means an order inserted ABOVE it
 * lands just out of sight. If the customer was already at the top, keep them there so
 * the new order is the first thing they see; if they had scrolled down, leave them be.
 */
LaunchedEffect(newestId) {
    if (listState.firstVisibleItemIndex <= 1) listState.animateScrollToItem(0)
}

Keyed on the newest order's id, the effect runs when a new order arrives at the top. A customer who had scrolled down to an old order is left exactly where they were, which is the anchoring doing its job.

(The iOS version of this app makes the same point with ForEach and Identifiable: identity is not a performance detail, it is correctness, and the speed comes for free once it is right.)

Two rules for keys: they must be unique within the list (a duplicate key crashes), and they must be saveable — a String, Int or Long — because the list's scroll position is saved by key across rotation. An id from the API is ideal.

contentType

A lazy list recycles compositions as items scroll off screen. contentType tells it which items are interchangeable: a product row can be reused for another product row, but never for the picker or a heading, which have completely different structure. Without it, reuse still works but does more work, because the recycled slot's tree has to be thrown away and rebuilt. With one item type it does not matter; with several, it is a one-word win.

A lazy list inside a scrolling parent

Sooner or later every Android developer puts a LazyColumn inside a Column with verticalScroll, and gets this crash:

java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity
maximum height constraints, which is disallowed.

A scrolling parent offers its children unlimited height — that is what scrolling means. A lazy list given unlimited height would have to compose every item to find its own size, which defeats the point of being lazy, so it refuses. The cart sheet has this shape — a scrolling sheet with a list inside — and the fix is to give the list a bound:

// Bounded, so a long cart scrolls inside the sheet and the totals stay reachable.
LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {

Now the list scrolls inside a fixed-maximum area, and the totals and the checkout button below it stay reachable however long the cart gets. The other fix, when the whole screen should scroll as one, is the reverse: make the outer container the lazy one and put the other content in item { } blocks, as the menu does.

Pull to refresh

is UiState.Loaded -> PullToRefreshBox(
    isRefreshing = isRefreshing,
    onRefresh = onRefresh,
    modifier = modifier.fillMaxSize(),
            // …

PullToRefreshBox wraps a scrollable child and shows the indicator while isRefreshing is true. The flag comes from the ViewModel, set for exactly as long as the reload actually takes:

fun refresh() {
    viewModelScope.launch {
        _isRefreshing.value = true
        try {
            menuStore.reload()
        } finally {
            _isRefreshing.value = false
        }
    }
}

menuStore.reload() suspends until the request finishes, so the indicator tracks the real request — not a fixed delay guessed to look about right. And the finally clears it even when the reload fails, so a failed refresh cannot leave the spinner stuck on screen.

How Compose decides what to skip

When state changes, Compose recomposes the composables that read it — and for each child those call, it asks: have the parameters changed? If not, the child is skipped. A list row that is skipped costs almost nothing, which is why skipping is most of Compose performance.

The question is how Compose knows whether a parameter changed. For years the answer was stability: a type is stable if Compose can trust that equal instances stay equal. Primitives, String and data classes made only of vals of stable types are stable. List<T> is not — it is an interface, and some implementations are mutable — so any composable taking a list was never skipped, and performance advice filled up with wrapper classes and immutable-collection libraries.

Most of that advice is now out of date. Since Kotlin 2.0.20 the Compose compiler runs with strong skipping on by default:

  • A composable with unstable parameters can still be skipped; those parameters are compared by instance (===) instead of by equals. A list that is the same list object as last time skips. Since this app replaces state rather than mutating it (the state lesson), an unchanged list is the same object.
  • Lambdas passed to composables are remembered automatically, so passing onClick = { onProductClick(product) } no longer defeats skipping.

When you still annotate

Two annotations remain useful, and this app uses each once. @Immutable promises that an instance never changes after construction:

@Immutable
data class PizzaColors(
    val primary: Color = Palette.Red,
    val primaryDark: Color = Palette.RedDark,
    val primarySoft: Color = Palette.RedSoft,
    val onPrimary: Color = Palette.White,
    // …

The theme's colours are read by nearly every composable; promising they are immutable lets Compose compare them with equals and skip on equal-but-different instances. @Stable makes a weaker promise — that observable changes go through Compose state — and is what a state-holder class with mutableStateOf properties should carry:

@Stable
class PizzaBuilderState(
    val product: Product,
    private val catalogue: Catalogue,
) {
    // …

These are promises, not hints. Annotate a class @Immutable and then mutate it, and Compose will skip composables that needed to update — a bug far harder to find than a slow list.

What is still your job

  • Keep item bodies cheap. No sorting, filtering or formatting large data inside items { }. Do it once, in the ViewModel or with remember.
  • Read fast-changing state as late as possible. A value read during composition recomposes on every change; a scroll offset read inside a Modifier.offset { } lambda is read during layout instead and skips composition entirely.
  • Use derivedStateOf for thresholds. "Has the user scrolled past the first item?" changes twice; the scroll position changes every frame.
  • Use a grid where a grid belongs. LazyVerticalGrid(GridCells.Adaptive(…)) fills a tablet with as many columns as fit, from the same item code.

Measure, in a release build

A debug build runs with Compose's debugging aids on and without R8's optimisations, and can be several times slower than what customers run. Judge scroll performance only in a release build. The Layout Inspector in Android Studio shows recomposition and skip counts per composable — a row whose recompose count climbs while its skip count stays at zero is the one to look at. And a baseline profile — a list of hot code paths shipped with the app so it is compiled ahead of time — is the single biggest improvement to first-launch and first-scroll smoothness, covered in the build lesson.

Next

The cart and the builder are mostly taps. Checkout is typing, and on a phone the keyboard is part of the interface. The next lesson is text fields, the keyboard, focus and validation.