Diagnosing Unnecessary Recompositions in Jetpack Compose
Step-by-step diagnostic guide to identify and fix unnecessary Compose recompositions causing UI jank, with a symptom table, ordered verification checks, targeted fixes, and escalation criteria.
09 Feb 2026, 12:07 UTC

When Jetpack Compose UI stutters during scroll or animation, the root cause is often unnecessary recomposition. This guide helps you pinpoint why and how to fix it.
Recognizable Condition
You notice one or more of the following: UI stutters during scroll or animation, Profile GPU Rendering shows bars exceeding 16 ms, Layout Inspector recomposition counts climb rapidly without user interaction, Perfetto traces reveal high CPU usage in androidx.compose slices.
Diagnostic Table
| Symptom | Likely Cause | Quick Check |
|---|---|---|
| Bars >16 ms in Profile GPU Rendering | Unstable lambda or object allocated per recompose | Check Logcat for composition entries during idle |
| Recomposition counts rise in Layout Inspector | Missing @Stable or non-comparable parameter types | Enable Show recomposition counts and observe hot paths |
| Side-effect keys change every composition | LaunchedEffect/DisposableEffect keys not stable | Inspect key expressions for literal changes |
| New Modifier.Node objects inside loops | Modifier.padding(dp) inside LazyColumn item lambda | Extract padding to a remember-d constant |
Ordered Verification Checks
- Build and run the app in a debug build: ./gradlew assembleDebug. Install on a device or emulator with graphics debugging enabled.
- Open Layout Inspector, enable Show recomposition counts. Interact with the UI and note which composables show counts >0 after an idle period; hot paths should drop to near zero.
- Capture a Perfetto trace: adb shell perfetto --start --cpu-frequency --track android.ui,androidx.compose --track android.gfx. Stop after a scroll session. Open the trace in ui.perfetto.dev, examine androidx.compose slices for frame duration. Look for frames >16 ms.
- Enable compiler metrics. In build.gradle.kts, add kotlinOptions { freeCompilerArgs += -Xprint-ir } or the stability plugin flag. Run ./gradlew assembleDebug and inspect the generated JSON for $stable flags on composable parameters.
- Write a ComposeTestRule test asserting recomposition count: composeTestRule.onNode(...).assertRecompositionCountEquals(0) after a delay. Requires compose-ui-test-junit4 1.6+.
- Benchmark with Macrobenchmark: ./gradlew :benchmark:connectedBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments=macrobenchmark.name=FrameTiming. Measure p99 frame time; target <16 ms on target device fleet.
Targeted Fixes per Cause
Unstable lambda/object allocation
When a lambda or object is created inside a composition, Compose cannot skip recomposition of children that reference it. Hoist state or move the lambda outside the composition, or annotate the type with @Composable @Stable if it is truly immutable.
@Composable fun ArticleList(items: List) { // Anti-pattern: lambda captured inside composition items.forEach { item -> Button(onClick = { /* ... */ }) { /* ... */ } } }Fix: move the onClick logic out or hoist item state.
MutableState read in parent, written in child without hoisting
Parent recomposes when child state changes. Hoist the mutableStateOf to the nearest stable ancestor, or use StateFlow/MutableStateFlow with proper collection.
Non-skippable composable (missing @Stable/@Immutable)
Add @Stable to data classes passed down and mutated via copy-with, or ensure they are @Immutable. Avoid @Stable on truly mutable classes.
@data class User(val name: String, val age: Int) @Stable class UserData { ... }Side-effect keys change every composition
Stabilize LaunchedEffect keys: use remember { key } or derive a stable key. Avoid passing unstable objects as keys.
LaunchedEffect(key1, key2) { /* ... */ }Better: LaunchedEffect(remember(key)) { /* ... */ }
Modifier chains allocating new objects inside loops
Extract Modifier.padding to a remember-d constant: val listItemPadding = remember { Modifier.padding(8.dp) } and use it outside the loop.
val listItemPadding = remember { Modifier.padding(8.dp) } LazyColumn { items(items) { item -> Text(text = item.title, modifier = listItemPadding) } }Escalation Criteria
If after applying all targeted fixes recomposition counts remain high, frame times exceed 16 ms on release builds, and compiler metrics show no $stable flags, consider: profiling with Perfetto's compose and scheduler categories, checking for custom layout implementations that bypass Compose semantics, filing a bug with the Compose team including the trace and Layout Inspector export, or refactoring to a UI declarative pattern such as UI slots or a ViewBinding hybrid.
Limitations and Practical Verification
- Debug builds show higher recomposition counts due to additional stability checks; release builds may hide some diagnostics.
- Layout Inspector recomposition counts are approximate; always corroborate with Perfetto trace and Macrobenchmark results.
- @Stable on mutable classes breaks Compose assumptions and can cause subtle bugs; only annotate immutable or snapshot-state-backed types.
- derivedStateOf defers calculation but does not prevent recomposition; reading unstable snapshot state inside it still triggers composition restarts.
- Modifier.Node allocation is cheap but compounds in tight loops; extract to remember or static constants.
- Compiler metrics require Compose Compiler 1.5.10+ and Kotlin 1.9+. Older versions lack detailed stability diagnostics.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.