Diagnosing Excessive Recompositions in Jetpack Compose LazyColumn
A diagnostic guide for UI stutter in Jetpack Compose LazyColumn caused by excessive recompositions. Covers recognizable symptoms, a cause table, ordered checks with Layout Inspector and Profiler, fixes tied to findings, and escalation criteria.
07 Jan 2026, 19:15 UTC

Problem: scroll stutter that disappears when the list is simple
When a LazyColumn scrolls with dropped frames or visible jank, especially with complex item layouts, the usual culprit is not GPU draw cost but too many recompositions firing per frame. A single scroll gesture should ideally cause 0-1 recompositions per visible item. When items recompose repeatedly, frame time exceeds ~16ms and the UI stutters.
Useful takeaway: stabilize identity with keys and hoist unstable reads out of item content. Most fixes are structural, not micro-optimizations.
Recognizable condition
- UI stutter or dropped frames on fling, worse with more items.
- Recomposition highlights in Layout Inspector flash across the whole list instead of only newly visible items.
- CPU profiler shows Compose recomposition work dominating the frame, often with allocation spikes.
Cause and diagnostic table
| Symptom | Likely cause | Quick signal |
|---|---|---|
| All visible items recompose on scroll | Unstable or missing item keys | key { } not supplied, or key uses unstable object | Single item recomposes repeatedly while off-screen | Mutable state read inside item content | State read from a parent scope inside LazyColumn item lambda | First scroll is slow, subsequent scrolls improve slightly | Expensive calculation without remember | Heavy mapping, formatting, or image decoding in composable body | Recomposition triggered by unrelated UI change | derivedStateOf reading unstable state | derivedStateOf depends on var or non-State object | Repositioning flicker after data update | Missing key leads to repositioning | Items swap visual state after list mutation |
Ordered checks
1. Observe recomposition with Layout Inspector
Open Android Studio, run the app with debuggable=true on a device or emulator. Tools > Layout Inspector > enable Show Recomposition Counts. The inspector adds overhead, use it only for debugging and disable in release builds.
Expected check: during a scroll gesture, recomposition count per item should stay low, ideally 0-1. If counts climb steadily, note which items flash.
2. Count recompositions per item with logging
Add a lightweight counter inside the item composable to see frequency without profiler overhead. Run from app module.
@Composable
fun ListItem(item: Item) {
val recomposeCount = remember { mutableStateOf(0) }
SideEffect { recomposeCount.value++ }
Log.d("ComposeDiag", "Item ${item.id} recomposed ${recomposeCount.value}")
// content
}Risk: logging in production is noisy and impacts performance. Remove after diagnosis.
3. Verify keys
Check LazyColumn declaration for stable keys.
LazyColumn(
items = items,
key = { it.id }
) { item ->
ListItem(item)
}Expected check: key selector returns a stable, unique value for the lifetime of the item. Avoid using index or unstable data class instances.
4. Profile frames and allocations
Android Studio Profiler > CPU > Record. Scroll the list and inspect the Frames timeline. Also open the Allocation tracker to spot excessive object creation per frame.
Expected check: frame time stays under 16ms for 60fps. Allocation spikes correlate with recompositions.
5. Check compiler metrics
Run from project root with Gradle permissions:
./gradlew assembleDebug -Pandroid.experimental.composeCompiler.reportsDestination=build/compose-reportsInspect the stability report for item composables. Skippable and stable parameters reduce unnecessary recompositions.
Fixes tied to findings
Unstable keys
Replace missing or unstable keys with a stable id. If items lack an id, introduce one in the ViewModel layer rather than synthesizing from position.
State reads inside items
Hoist state to the parent and pass values as parameters. Reading a mutableState from a parent scope inside the item lambda causes the whole item to recompose when that state changes.
val selectedId by viewModel.selectedId.collectAsState()
LazyColumn(items = items, key = { it.id }) { item ->
ListItem(item, isSelected = item.id == selectedId)
}ListItem becomes a pure function of its parameters.
Expensive work without remember
Extract heavy calculations to a sub-composable and wrap with remember.
@Composable
fun ExpensiveContent(raw: String) {
val processed = remember(raw) { heavyTransform(raw) }
Text(processed)
}Avoid placing I/O or heavy calculations directly in composables. Move work to ViewModel or coroutines and remember the result.
derivedStateOf misuse
Ensure derivedStateOf only reads stable State objects and is not recreated on every recomposition.
val filtered by remember(items) {
derivedStateOf { items.filter { it.active } }
}If the source is unstable, stabilize it first.
Verification
- Run on debuggable build, open Profiler Frames timeline and confirm frame times stay under 16ms during scroll.
- Layout Inspector recomposition count per item remains low during scroll.
- Compose Compiler Metrics show item composables marked skippable with stable keys.
Limitations: Layout Inspector and logging add overhead and can mask the issue. Verification should be done on a representative device, not only an emulator.
Escalation criteria
If after applying key stabilization, state hoisting, and remember, recompositions still exceed ~60 per second or frame time remains >16ms, reduce item complexity, use PagingData with LazyColumn for large data sets, or consider migrating heavy lists to RecyclerView with ComposeView for better recycling.
Escalate when allocation tracker shows persistent object churn from Compose internals, indicating a deeper stability issue that requires compiler metrics review and architectural changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.