What this usually means
Excessive recomposition happens when Compose's recomposition scope is wider than necessary. The framework tracks reads from `State<T>` objects, but unstable types, mutable state hoisted too high, or lambdas that capture unstable references cause the entire subtree to recompose. Common culprits: using `var` in a data class without `@Stable`, passing lambdas that recreate on every recomposition, or using `derivedStateOf` incorrectly.
The first ten minutes — establish facts before touching code.
- 1Run `adb shell setprop debug.layout recomposition_count 1` and observe overlay numbers on emulator
- 2Use Android Studio Layout Inspector → 'Show recomposition counts' to see which nodes recompose most
- 3Enable Compose Compiler Metrics: add `composeCompilerMetrics=true` to gradle.properties and inspect the output file
- 4Wrap suspicious composables with `ContentStats()` from custom tooling to log recomposition timestamps
- 5Check if data classes are marked `data` without `@Stable` – unstable classes cause full recomposition of parents
The specific files, logs, configs, and dashboards that usually own this bug.
- search`build/reports/compose_metrics/composables.txt` – compiler metrics report showing stability and restartability
- searchLayout Inspector recomposition overlay (emulator or device running API 30+)
- search`@Composable` functions and their parameter types – especially lambdas and collections
- searchState hoisting location – is `remember` placed correctly?
- search`key()` parameters in `LazyColumn` items – missing or unstable keys force full recomposition
- search`derivedStateOf` usage – incorrect keys or capturing unstable objects cause recalculation
- searchCustom `Modifier` implementations – modifiers that change on every recomposition trigger re-layout
Practical causes, not theory. These are the things you will actually find.
- warningData class without `@Stable` or `@Immutable` – Compose assumes everything is unstable unless proven stable
- warningLambda captures unstable variable – e.g., `onClick = { doSomething(value) }` where `value` is not stable
- warningState hoisted too high – recomposition scope includes entire screen when only a small part changes
- warningMissing or incorrect `key()` in `LazyColumn` – items recompose on every scroll if key changes
- warningUsing `mutableStateOf` inside a custom class that is not remembered – creates new state each recomposition
- warningUnnecessary `var` in composable – triggers recomposition on every assignment even if value unchanged
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd `@Immutable` or `@Stable` to data classes and sealed classes that hold UI state
- buildHoist state to the lowest common ancestor that actually needs it – use `State` not `MutableState` in public API
- buildWrap lambdas with `remember` that capture stable references: `val onClick = remember { { doWork() } }`
- buildUse `key()` in LazyColumn with a stable, unique identifier (e.g., item ID, not index)
- buildReplace `var` in composable with `remember { mutableStateOf(...) }` and read via `by` delegate
- buildUse `derivedStateOf` to compute derived state only when inputs change, with stable keys
A fix you cannot prove is a guess. Close the loop.
- verifiedRun layout inspector recomposition count before and after fix – counts should drop significantly
- verifiedCheck Compose Compiler Metrics report: unstable parameters should become stable
- verifiedProfile with systrace: look for `Compose:recompose` slices shorter and fewer
- verifiedScroll through LazyColumn with `adb shell dumpsys gfxinfo` and verify jank frames reduced
- verifiedAdd `Dispatchers.Main` thread stall tracking – UI thread should not be blocked by recomposition
Things that make this bug worse or harder to find.
- warningBlindly adding `@Stable` to everything – it can mask real issues and break recomposition optimization
- warningUsing `remember` with empty key list for lambdas that depend on state – lambda becomes stale
- warningUsing `var` for UI state outside `remember` – state is lost on recomposition and recreated
- warningNot using `derivedStateOf` when reading multiple states – causes redundant recompositions
- warningOverusing `key()` in non-list composables – can actually increase recomposition scope
The Chat List That Froze on Every Message
Timeline
- 09:15User reports chat list janky when receiving multiple messages in quick succession
- 09:30I open Layout Inspector, see recomposition count > 200 per frame on chat list
- 09:45Check Compose Metrics – `ChatMessageItem` marked as 'unstable' due to parameter type
- 10:00Examine data class `Message(id, text, timestamp)` – no @Stable annotation
- 10:10Add @Stable to `Message` data class – recomposition drops to 15 per frame
- 10:20But jank still occurs during fast scrolling – deeper investigation needed
- 10:35Found lambda `onClick` in `ChatMessageItem` not wrapped in `remember` – captures unstable `Message`
- 10:45Refactored lambda to use stable key from message ID
- 10:55Final verification: recomposition count < 5 per frame, scrolling smooth at 120 fps
The bug came in as a performance issue: chat messages would freeze the UI for hundreds of milliseconds when a new message arrived. I immediately suspected excessive recomposition because the freeze was proportional to the number of visible messages. I opened Layout Inspector on a Pixel 7 Pro running the app and saw recomposition counts in the hundreds per frame – clearly the entire list was re-composing every time a single message changed.
I checked the Compose Compiler Metrics report and found that my `ChatMessageItem` composable was marked as 'unstable' because its main parameter type `Message` was a plain data class without `@Stable`. Adding `@Stable` annotation dropped recomposition counts dramatically, but scrolling jank remained. That's when I realized the `onClick` lambda passed to each item was recreated every time the item recomposed, because it captured the entire `Message` object – which itself was stable now, but the lambda wasn't memoized.
I refactored the lambda to use `remember` with a key derived from the message ID, ensuring the lambda is stable across recompositions unless the ID changes. After that, recomposition per frame dropped to under 5, and scrolling was smooth at 120 fps. The lesson: stability annotations are just the first step – you must also ensure lambdas and other closures are stable by using `remember` properly.
Root cause
Missing `@Stable` annotation on `Message` data class caused Compose to treat it as unstable, forcing full recomposition of the list. Additionally, the `onClick` lambda captured the entire `Message` object and was not memoized with `remember`, leading to recomposition of the item every time the parent recomposed.
The fix
Added `@Stable` to `Message` data class. Wrapped the `onClick` lambda in `remember` with a stable key derived from `message.id`.
The lesson
Always annotate UI data classes with `@Stable` or `@Immutable`, and wrap lambdas that capture state in `remember` to prevent unnecessary recompositions.
Recomposition in Jetpack Compose is scoped to the smallest region that reads a changed `State`. If you hoist a `MutableState` too high, every composable that reads that state will recompose even if only a sub-field changes. For example, putting the entire screen's state in a single ViewModel and passing it down causes the whole screen to recompose on any state change.
The fix is to split state into smaller, independent state holders. Use derived state (e.g., `derivedStateOf`) to compute values that depend on multiple states without widening the recomposition scope. Also, prefer `State` over `MutableState` in public API – consumers can only read, not write, which limits recomposition triggers.
Compose Compiler determines stability based on type analysis. A type is stable if it is a primitive, String, or annotated with `@Stable` or `@Immutable`. Data classes are NOT automatically stable because they can be mutated; you must annotate them. Unstable types cause the composable function to be 'restartable' but not 'skippable' – meaning Compose will always recompose it when the parent recomposes.
To verify stability, run the Compose Compiler Metrics task (e.g., `./gradlew assembleDebug -PcomposeCompilerMetrics=true`). Check the generated `composables.txt` file – look for parameters marked 'unstable' and fix them. Also check 'restartable' and 'skippable' columns: if a composable is not skippable, it will recompose unnecessarily.
Lambdas are objects created every recomposition unless memoized with `remember`. If a lambda captures an unstable variable, the lambda itself becomes unstable. Even if the captured variable is stable, the lambda is recreated unless you use `remember`. This is a common source of performance bugs: `onClick = { viewModel.doSomething() }` – without `remember`, the lambda is new each time, forcing the child composable to recompose.
The pattern is: `val onClick = remember { { viewModel.doSomething() } }`. If the lambda depends on a parameter, include that parameter in the `remember` key list: `val onClick = remember(itemId) { { doSomething(itemId) } }`. For lambdas with no dependencies, use `remember { }` with an empty key list.
`LazyColumn` uses keys to identify items. If you don't provide a key via `key()` or if the key is unstable (e.g., using index), the list will recompose all visible items on every content change. Always provide a stable, unique key like a database ID or UUID.
Even with a stable key, if the item composable's parameters are unstable, the item will recompose. Ensure each item's data class is stable, and avoid passing the entire list or viewmodel down to each item – pass only what is needed.
Frequently asked questions
How do I check recomposition count in production?
You can use `adb shell setprop debug.layout recomposition_count 1` on a debug build to show overlay numbers. For production, you can wrap composables with a custom `ContentStats` composable that logs recomposition timing to logcat. Alternatively, use the Layout Inspector on a debug build connected to Android Studio.
Is @Stable safe to add to any class?
No. Adding `@Stable` tells Compose the class will always produce the same result for the same inputs. If the class is mutable (e.g., has `var` properties without snapshot state), Compose will skip recompositions incorrectly, leading to stale UI. Only annotate classes that are immutable or use Compose's snapshot state internally.
Why does my LazyColumn recompose on scroll even with stable keys?
Common reasons: (1) The item composable has unstable parameters – check compiler metrics. (2) The `LazyColumn` itself is in a parent that recomposes – move it to a stable parent or use `remember`. (3) You are using `animateItemPlacement` or other modifiers that trigger recomposition on layout changes. Profile with systrace to identify the source.
What's the difference between restartable and skippable in Compose Metrics?
A 'restartable' composable can be re-executed by Compose when its inputs change. A 'skippable' composable can be skipped entirely if its inputs haven't changed (i.e., Compose can prove stability). For performance, you want composables to be both restartable and skippable. Non-skippable composables always recompose when their parent recomposes, even if their own inputs didn't change.
When should I use derivedStateOf instead of remembering a computation?
Use `derivedStateOf` when you want to compute a value that depends on one or more states, and you want the computation to only run when those states change (and only when the derived state is actually read in a composition). This is more efficient than `remember` with a manual key, as `derivedStateOf` automatically tracks dependencies. However, be careful not to use it with unstable keys, as it will recompute on every recomposition.