Phase 5 · Advanced ComposeModule 28~28 min read

Performance & Best Practices

Keep apps smooth by understanding recomposition, stability, and how to avoid needless work.

What you'll learn

Compose is fast by default, but a few patterns can make it do needless work and drop frames. The good news: performance in Compose is mostly about recomposing less, and once you understand why recomposition happens you can avoid the common traps. This is the polish that makes an app feel buttery.

By the end you'll be able to:

  • Explain recomposition and what triggers it, in depth
  • Understand stability and @Immutable/@Stable
  • Avoid unnecessary recomposition
  • Use keys, derivedStateOf, and deferred reads; and profile your UI

Recomposition, deeper

Recomposition is Compose re-running composables to update the UI when state changes. The key insight: Compose is smart about it — it only re-runs composables that read state that changed, and skips ones whose inputs are unchanged. Performance problems almost always come from breaking that skipping: recomposing far more than necessary, often the whole screen when one row changed.

Key idea

The golden rule of Compose performance: read state as late (as low in the tree) as possible. The composable that reads a value is the one that recomposes when it changes — so if you read it high up, everything below re-runs. Push reads down to the smallest composable that needs them.

Stability

Compose can only skip a composable if it's sure its parameters haven't changed — which requires those types to be stable. A type is stable if Compose can trust that it won't mutate behind its back: all val properties of stable types. A MutableList or a var is unstable, and an unstable parameter forces recomposition even when the value looks the same:

Stability.kt
// ❌ Unstable: a mutable List can change without Compose knowing.
data class UiState(val items: List<Item>, val tags: MutableList<String>)

// ✅ Stable: immutable, and marked so Compose can skip when unchanged.
@Immutable
data class UiState(val items: ImmutableList<Item>, val tags: ImmutableList<String>)

Prefer immutable data (val, List not MutableList). For lists, the kotlinx.collections.immutable types (ImmutableList) are guaranteed stable. You can also promise stability with @Immutable or @Stable when you know a class won't change but Compose can't prove it.

Avoiding wasted work

Common fixes that eliminate most performance issues:

AvoidWaste.kt
// ❌ Passing a new lambda + reading state high up can recompose the whole list.
Column {
    items.forEach { item ->
        ItemRow(item = item, onClick = { onSelect(item.id) })
    }
}

// ✅ Use a LazyColumn with keys; hoist reads down so only affected rows recompose.
LazyColumn {
    items(items, key = { it.id }) { item ->
        ItemRow(item = item, onClick = onSelect)   // stable reference
    }
}
  • Use LazyColumn for long lists, always with a key.
  • Keep UI-state data classes immutable and stable.
  • Don't do heavy work in composition. Remember expensive computations with remember(key) so they don't re-run every recomposition.

Keys, derivedStateOf & deferred reads

Three targeted tools. Keys in lazy lists let Compose track items across changes (Module 11). derivedStateOf stops a rapidly-changing value from recomposing when only a derived result matters (Module 25). And deferred reads — reading state inside a lambda modifier like graphicsLayer or drawBehind — push a fast-changing read (scroll, animation) into the layout or draw phase, skipping recomposition entirely:

DeferReads.kt
// ❌ Reads scroll on every frame → recomposes this composable constantly.
val offset = scrollState.value
Header(alpha = 1f - offset / 300f)

// ✅ Defer the read to the drawing phase with a lambda modifier.
Header(modifier = Modifier.graphicsLayer {
    alpha = 1f - scrollState.value / 300f      // read happens at draw time
})

Profiling tools

Don't guess — measure. Android Studio's Layout Inspector shows a live view of your composables with recomposition counts: a composable recomposing thousands of times while you barely interact is your culprit. The Compose Compiler Reports tell you which composables are skippable and which parameters are unstable. And always test performance on a release build — debug builds are much slower and misleading.

Watch out

Don't optimize prematurely. Write clear, idiomatic Compose first; it's fast enough for the vast majority of screens. Reach for these techniques when you measure a real problem — janky scrolling, a slow screen — not on a hunch. Premature micro-optimization just adds complexity.

Recap & quick check

Key takeaways

  • Recomposition re-runs composables that read changed state; Compose skips those with unchanged, stable inputs.
  • Golden rule: read state as low in the tree as possible so fewer composables recompose.
  • Stable/immutable parameters let Compose skip; MutableList and var are unstable — prefer val and ImmutableList.
  • Use LazyColumn + keys, remember expensive work, and defer fast-changing reads to graphicsLayer/drawBehind.
  • Measure with the Layout Inspector's recomposition counts and Compiler Reports — on a release build; don't optimize prematurely.

Quick check

1. What triggers a composable to recompose?

2. The golden rule of Compose performance is…

3. Why can an unstable parameter (e.g. MutableList) hurt performance?

4. How do you make a fast-changing scroll read NOT trigger recomposition?

5. Best way to find a recomposition problem?

That completes Phase 5 — your apps are polished and performant! 🎉 In Phase 6 we go professional: dependency injection with Hilt, testing, and publishing to the Play Store.