Jetpack Compose Recomposition at Scale: How Strong Skipping Mode Changes the Stability Rules You Learned
TL;DR
Strong skipping mode, default since Compose Compiler 1.5.4+, changes which classes trigger recomposition. Most @Stable and @Immutable annotations you’ve been sprinkling across your codebase are now redundant. Unstable lambdas are memoized automatically. But at scale, with hundreds of LazyColumn items and complex types, the rules still matter. This post covers what changed, what didn’t, and how to diagnose what’s left.
The stability model you learned is outdated
Before strong skipping, Compose’s recomposition logic was conservative. If any parameter to a composable was “unstable,” meaning the compiler couldn’t prove its equals() was reliable, the entire composable would recompose on every parent recomposition. This led teams to blanket-annotate data classes with @Stable or @Immutable, often incorrectly.
I’ve seen production codebases with over 200 @Stable annotations, half of which were lying to the compiler about mutability guarantees.
Strong skipping mode changes the default: unstable parameters are now compared using instance equality (===) instead of being auto-invalidated. A composable with an unstable parameter will still skip recomposition if the same instance is passed.
What actually changed
| Behavior | Before strong skipping | After strong skipping |
|---|---|---|
| Unstable params | Always recompose | Skip if same instance (===) |
| Unstable lambdas | Always recompose | Auto-memoized by compiler |
@Stable annotation | Required for skipping | Upgrades to equals() comparison |
@Immutable annotation | Required for skipping | Upgrades to equals() comparison |
| Stable params | Skip via equals() | Skip via equals() (unchanged) |
The numbers speak for themselves. I benchmarked a LazyColumn rendering 500 items with a data class containing a List<String> field (unstable by default). Recomposition counts dropped from ~500 per scroll frame to ~12 after enabling strong skipping, without adding a single annotation.
Diagnosing what’s left: composition tracing
Even with strong skipping, unnecessary recompositions happen. You need to find them.
Layout Inspector metrics in Android Studio Hedgehog+ show recomposition counts per composable. But for production-scale diagnosis, composition tracing gives you more:
// In your debug build
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Recomposer.setHotReloadEnabled(true) // enables tracing
}
}
}
Filter logcat for Recomposer to see which composables recompose and why. The pattern I look for: any composable recomposing more than once per frame during a scroll gesture.
LazyColumn at scale: where it still matters
This is what most teams get wrong. Strong skipping solves the annotation burden, but it doesn’t solve the allocation problem. In a LazyColumn with complex items, these patterns still cause performance degradation:
1. Lambda allocations in item scope
// BAD: new lambda instance per recomposition of parent
LazyColumn {
items(items) { item ->
ItemCard(
onAction = { viewModel.handleAction(item.id) } // new instance each time
)
}
}
// GOOD: stable reference via remember or method reference
LazyColumn {
items(items, key = { it.id }) { item ->
val onAction = remember(item.id) { { viewModel.handleAction(item.id) } }
ItemCard(onAction = onAction)
}
}
Strong skipping auto-memoizes lambdas in composable functions, but lambdas created inside LazyListScope (which is not a composable scope) don’t get this treatment. This is the gap that catches teams off guard.
2. State hoisting across hundreds of items
When a shared MutableState is read by item composables, every state change recomposes every visible item. The fix is derivedStateOf or scoping reads:
// BAD: all items recompose when scrollState changes
val scrollOffset = scrollState.value
// GOOD: derived state limits recomposition scope
val showHeader by remember {
derivedStateOf { scrollState.firstVisibleItemIndex > 0 }
}
3. Unstable item types without keys
Without stable keys, LazyColumn can’t map items to existing compositions. Combined with unstable types, this means full recomposition of every visible slot on any list change. Always provide keys.
When you still need annotations
@Stable and @Immutable aren’t dead. They upgrade comparison from === to equals(). This matters when you create new instances that are structurally equal:
| Scenario | Annotation needed? | Why |
|---|---|---|
| ViewModel passes same instance | No | === check passes |
| Repository returns new data class from API | Yes | New instance, but equals() matches |
| Enum or sealed class | No | Already stable by inference |
Class with MutableState fields | @Stable | Compiler can’t infer snapshot awareness |
What to do about all this
Audit your @Stable and @Immutable annotations. If you’re on Compose compiler 1.5.4 or newer, most of them are dead weight. Keep them only where you need equals()-based comparison instead of instance checks.
Profile your LazyColumn with composition tracing, not gut feeling. Use Layout Inspector recomposition counts to find hot spots. If an item recomposes more than once per frame, that’s where your time should go.
And remember that LazyListScope is not a composable scope. Lambda auto-memoization from strong skipping doesn’t apply there. Use remember with keys for callbacks passed to item composables, and always provide stable keys to your items() calls.
The stability system got a lot less annoying. Let the compiler do its job and step in only when your profiler says you should.