Compose Performance: Recomposition Traps, Keys & Stability
Know why Compose skips recomposition, when it fails to skip, and how to fix it — the difference between a smooth 60fps UI and a janky one
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Compose performance centers on recomposition — how selectively Compose re-runs composable functions. Composables are skipped (not recomposed) when all their parameters are stable and unchanged. @Stable and @Immutable annotations inform the compiler about stability contracts. Unstable types like stdlib List break skipping. key() provides list item identity. derivedStateOf prevents reading rapidly-changing state directly. The Compose compiler metrics and Layout Inspector are the diagnostic tools.
Real-world relevance
In a fintech app displaying a real-time transaction list, each TransactionRow composable must be skippable to maintain 60fps scrolling while prices update. Using ImmutableList instead of List, annotating the Transaction data class with @Immutable, passing a remembered lambda for the tap handler, and keying the LazyColumn items by transaction ID together eliminate unnecessary recompositions. Layout Inspector confirms rows not affected by a single price update are skipped entirely.
Key points
- Recomposition basics — Compose re-runs composable functions when their inputs (state or parameters) change. This is efficient when composables are skipped — Compose compares parameters and skips recomposition if nothing changed. Skipping requires all parameters to be stable and equal to previous values.
- Skippable composables — A composable is skippable if ALL its parameters are stable types. Stable means the type either never changes (immutable) or Compose is notified when it does (implements Stable contract via @Stable annotation or is a primitive/String/MutableState). Skippable composables are only recomposed when their inputs actually change.
- @Stable annotation — @Stable tells the Compose compiler: 'if two instances of this type are equal() they are identical from Compose's perspective, AND if the object changes it will notify Compose.' It is a promise — if you lie and mutate @Stable objects silently, you get stale UI bugs. Use on classes with observable mutation (like SnapshotMutableState).
- @Immutable annotation — @Immutable is a stronger promise: the object and all its fields will NEVER change after construction. Compose trusts this completely and will always skip recomposition when the parameter value is equal. Use on data classes that are true value objects — if you annotate a class that mutates, you'll get ghost bugs.
- Unstable types that break skipping — List, Map, Set from Kotlin stdlib are considered UNSTABLE by Compose — they are interfaces and could be backed by mutable implementations. Passing a List as a parameter makes the composable non-skippable. Fix: use kotlinx.collections.immutable's ImmutableList or wrap in a @Immutable data class wrapper.
- The key() composable for list identity — In LazyColumn items(list, key = { it.id }) — key gives Compose a stable identity for each item. Without key, adding an item at position 0 causes Compose to treat item 0 as 'changed' and recompose all items. With key, Compose tracks by identity — only the truly new item is composed.
- Layout Inspector recomposition highlights — Android Studio's Layout Inspector has a 'Recomposition counts' overlay that shows how many times each composable recomposed. Use this to find unexpected hot composables. A composable recomposing 60x/sec when only 1 item in a list changed is a red flag for missing stability or missing keys.
- derivedStateOf to reduce recomposition — val isScrolled by remember { derivedStateOf { scrollState.firstVisibleItemIndex > 0 } } — the Boolean only changes when crossing 0, not on every scroll pixel. Without derivedStateOf, scrollState reading inside the composable triggers recomposition on every scroll frame. This is one of the most impactful micro-optimizations.
- Lambda stability and function references — Lambdas in Kotlin are compiled to anonymous classes that are new instances on every recomposition — making them unstable. onClick = { doSomething() } creates a new lambda each recomposition, making the child non-skippable. Fix: use remembered lambdas (val onClick = remember { { doSomething() } }) or stable function references to ViewModel methods.
- Compose compiler metrics — Run ./gradlew assembleRelease -PcomposeCompilerMetrics to generate reports showing which composables are skippable vs restartable, and which parameters are stable vs unstable. This is the authoritative tool for performance investigation — not guessing.
- Strong skipping mode (Compose 1.6+) — From Compose 1.6, 'strong skipping mode' makes lambdas stable by default (they are remembered automatically) and allows skipping composables with unstable params if inputs are referentially equal. Opt in via composeCompiler { enableStrongSkippingMode = true }. Reduces the need for manual stability annotations in many cases.
- Baseline Profiles for startup — Compose startup performance is improved by Baseline Profiles — pre-compiled instruction files that tell ART which code paths to AOT compile. Generate with the Macrobenchmark library and include the generated baseline-prof.txt in the app module. Critical for production Compose apps targeting fast cold start.
Code example
// Unstable param — composable is NOT skippable
@Composable
fun TransactionList(transactions: List<Transaction>) { // List<T> is unstable
LazyColumn {
items(transactions) { tx -> TransactionRow(tx) } // No key — bad
}
}
// Fixed: ImmutableList + key + @Immutable data class
@Immutable
data class Transaction(val id: String, val amount: Double, val label: String)
@Composable
fun TransactionListFixed(transactions: ImmutableList<Transaction>) { // Stable
LazyColumn {
items(transactions, key = { it.id }) { tx -> // Stable key
TransactionRow(tx)
}
}
}
// Lambda stability — new lambda on every recomposition = child not skippable
@Composable
fun BadParent(viewModel: TxViewModel = hiltViewModel()) {
TransactionRow(
tx = viewModel.tx,
onClick = { viewModel.select(viewModel.tx.id) } // New lambda every recomposition
)
}
@Composable
fun GoodParent(viewModel: TxViewModel = hiltViewModel()) {
val tx by viewModel.selectedTx.collectAsStateWithLifecycle()
val onSelect = remember(tx.id) { { viewModel.select(tx.id) } } // Stable lambda
TransactionRow(tx = tx, onClick = onSelect)
}
// derivedStateOf — FAB visibility without recomposing on every scroll frame
@Composable
fun TransactionScreen() {
val listState = rememberLazyListState()
// BAD: reads listState.firstVisibleItemIndex — recomposes on EVERY scroll frame
// val showFab = listState.firstVisibleItemIndex > 0
// GOOD: only recomposes when crossing the 0 threshold
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Box {
LazyColumn(state = listState) { /* items */ }
if (showFab) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Top")
}
}
}
}
// @Stable on a class with observable mutation
@Stable
class FilterState {
var query by mutableStateOf("") // Changes notify Compose via snapshot
var sortAscending by mutableStateOf(true)
}
// Wrapper for unstable stdlib collections
@Immutable
data class TransactionList(val items: List<Transaction>) // Wraps unstable List
// Compose compiler metrics — build.gradle.kts
// android {
// composeOptions {
// kotlinCompilerExtensionVersion = "..."
// }
// }
// tasks.withType<KotlinCompile>().configureEach {
// kotlinOptions.freeCompilerArgs += listOf(
// "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${rootProject.buildDir}/compose-metrics"
// )
// }Line-by-line walkthrough
- 1. List as a parameter type makes TransactionList non-skippable — Compose cannot prove the list is stable
- 2. @Immutable on Transaction promises the compiler: fields never change after construction — Compose will always skip equal instances
- 3. ImmutableList from kotlinx.collections.immutable is recognized as stable — passing it as a parameter restores skippability
- 4. items(transactions, key = { it.id }) — the key lambda provides stable identity; Compose tracks each item by ID, not by position
- 5. BadParent's onClick = { viewModel.select(...) } creates a new lambda object every recomposition — TransactionRow receives a new parameter and cannot skip
- 6. GoodParent wraps the lambda in remember(tx.id) — the same lambda instance is returned as long as tx.id does not change
- 7. val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } — reads scrollState inside derivedStateOf, which only notifies subscribers when the boolean result changes
- 8. Without derivedStateOf, reading listState.firstVisibleItemIndex directly in the composable body subscribes to every scroll event — recomposing the entire Box on every pixel scrolled
- 9. @Stable FilterState uses mutableStateOf fields — mutations go through the Compose snapshot system, which notifies only the composables that read the changed fields
- 10. The commented-out compiler metrics config shows how to enable the -P plugin flag that generates JSON reports listing every composable's stability and skippability status
Spot the bug
@Composable
fun PortfolioScreen(viewModel: PortfolioViewModel = hiltViewModel()) {
val stocks by viewModel.stocks.collectAsStateWithLifecycle() // StateFlow<List<Stock>>
// Bug 1
val gainers = stocks.filter { it.change > 0 }
Column {
// Bug 2
Text(
text = "Total: ${stocks.sumOf { it.price }}",
style = MaterialTheme.typography.headlineMedium
)
LazyColumn {
// Bug 3
items(gainers) { stock ->
StockRow(
stock = stock,
// Bug 4
onTap = { viewModel.selectStock(stock.id) }
)
}
}
}
}
data class Stock(val id: String, val ticker: String, val price: Double, val change: Double)Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Compose Performance — Official Guide (Android Developers)
- Stability in Compose (Android Developers)
- Jetpack Compose Stability Explained (Android Developers Medium)
- Compose Compiler Metrics (AndroidX GitHub)
- Optimizing Compose Performance — Google I/O 2023 (YouTube / Google)