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: you can measure composition, layout, drawing, I/O, and startup separately, then improve the work responsible for delays. Understanding recomposition helps, but counts alone do not establish which work drops frames. 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. Changed state invalidates the scopes that read it; changed parameters can also cause work when a parent runs. Compose can skip eligible children with unchanged inputs. Performance problems can involve unnecessary composition, but also image decoding, heavy layout, database work, allocation, and drawing. Use a trace to identify the cost before choosing a fix.
Key idea
Stability
Compose uses compiler-generated comparisons to decide whether an eligible composable can skip. Stability describes whether changes are observable and equality is reliable; a mutable type can be stable when changes use observable state correctly. With modern strong skipping, restartable composables can also skip with unstable inputs, compared by reference. A plain mutable list can still change invisibly and produce stale UI:
// Ordinary mutable collections do not notify Compose about in-place changes.
data class MutableUiState(val items: List<Item>, val tags: MutableList<String>)
// A genuine immutable model (requires kotlinx.collections.immutable):
@Immutable
data class Item(val id: String, val title: String)
@Immutable
data class ImmutableUiState(val items: ImmutableList<Item>, val tags: ImmutableList<String>)Prefer immutable data (val, List not MutableList). For lists, the kotlinx.collections.immutable types (ImmutableList) are recognized by the compiler; their element types still matter. A Kotlin read-only List is not a promise of deep immutability. You can promise immutability with @Immutable, or observable stability with @Stable, only when the entire type satisfies that contract. These annotations do not freeze a mutable object.
Avoiding wasted work
Common fixes that eliminate most performance issues:
// Eagerly composes every row; expensive for a very long feed.
Column {
items.forEach { item ->
ItemRow(item = item, onClick = { onSelect(item.id) })
}
}
// Lazy composition limits how many rows are needed at once.
LazyColumn {
items(items, key = { it.id }) { item ->
ItemRow(item = item, onClick = { onSelect(item.id) })
}
}- 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 (see Side Effects & Effect Handlers). 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:
// ❌ 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).coerceIn(0f, 1f)
// State read updates layer properties without recomposing Header.
})Profiling tools
Don't guess — measure. Android Studio's Layout Inspector shows a live view of your composables with recomposition counts in a debuggable build. Use these as clues, then inspect the cost of the relevant work. The Compose Compiler Reports explain skipping and stability. Measure real performance with a profileable, optimized release-like build and benchmarks; debug-build timings are misleading.
Watch out
Practice: one small working feature
Observe a list scroll in a release-like build using Android Studio profiling tools.
Check your result
You can name a measured slow operation and compare the same interaction before and after one change.
Remember
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.
- Strong skipping can skip restartable composables with unstable parameters too; mutation still needs observable state and truthful contracts.
- Use LazyColumn + keys, remember expensive work, and defer fast-changing reads to graphicsLayer/drawBehind.
- Use debug inspection for clues, then profileable release-like benchmarks and traces for actual timing; don't optimize prematurely.
Quick check
1. What triggers a composable to recompose?
2. The golden rule of Compose performance is…
3. What is the right response to an unstable collection parameter?
4. How do you make a fast-changing scroll read NOT trigger recomposition?
5. Best way to find a recomposition problem?
Apply these checks to the Phase 5 Study Preferences capstone. Trace its two Boolean values, change the settings, and verify the content at large font sizes.