| name | compose-performance-auditor |
| description | Audits and fixes Jetpack Compose runtime performance issues. Use when user says "Compose is slow", "too many recompositions", "janky scrolling", "frame drops", or asks to "optimize Compose UI". Performs code-first review and guides profiling when needed. |
Compose Performance Auditor
Overview
Audit Compose UI performance end-to-end: from code review to root-cause analysis and concrete fixes.
Workflow
Code available? ──Yes──▶ Code-First Review
│
No
▼
Describe symptoms ──▶ Ask for code/context ──▶ Code-First Review
│
If inconclusive
▼
Guide Profiling (Layout Inspector / Perfetto)
1. Code-First Review
What to Collect
- Target composable code
- Data flow: state,
remember, ViewModel connections
- Symptoms and reproduction steps
What to Look For
| Issue | Symptom |
|---|
| Recomposition storms | Whole tree recomposes on small state change |
| Unstable lambda captures | New lambda instance every recomposition |
Missing remember | Expensive object recreated every frame |
| Unstable class parameters | Compose can't skip recomposition |
| Heavy work in composition | Sorting / filtering / formatting during draw |
| Unstable keys in lazy lists | Items re-render on unrelated changes |
| State read too early | Entire composition recomposes instead of layout/draw phase |
| Large images without size constraints | Decoding full resolution unnecessarily |
| Deep nesting / intrinsic measurements | Layout pass is O(n²) |
2. Common Code Smells and Fixes
Unstable Lambda Captures
Button(onClick = { viewModel.doAction(item.id) }) { Text("Action") }
val onAction = remember(item.id) { { viewModel.doAction(item.id) } }
Button(onClick = onAction) { Text("Action") }
Expensive Work in Composition
@Composable fun ItemList(items: List<Item>) {
val sorted = items.sortedBy { it.name }
LazyColumn { items(sorted) { ItemRow(it) } }
}
@Composable fun ItemList(items: List<Item>) {
val sorted = remember(items) { items.sortedBy { it.name } }
LazyColumn { items(sorted) { ItemRow(it) } }
}
Missing Key in LazyColumn
LazyColumn { items(items) { ItemRow(it) } }
LazyColumn { items(items, key = { it.id }) { ItemRow(it) } }
Unstable Data Classes
data class UiState(val items: List<Item>, val isLoading: Boolean)
@Immutable
data class UiState(val items: List<Item>, val isLoading: Boolean)
@Immutable
data class UiState(val items: ImmutableList<Item>, val isLoading: Boolean)
State Read Too Early (Defer to Layout/Draw Phase)
@Composable fun ParallaxHeader(scrollState: ScrollState) {
val offset = scrollState.value
Image(modifier = Modifier.offset(y = offset.dp)) { ... }
}
@Composable fun ParallaxHeader(scrollState: ScrollState) {
Image(modifier = Modifier.offset { IntOffset(0, scrollState.value) }) { ... }
}
@Composable fun ParallaxHeader(scrollState: ScrollState) {
Box(modifier = Modifier.drawBehind {
val y = scrollState.value.toFloat()
drawRect(color = Color.Red, topLeft = Offset(0f, y))
})
}
derivedStateOf Misuse
derivedStateOf only observes State<T> reads inside its lambda. Captured plain values (unwrapped via by) are not tracked.
val animated by animateFloatAsState(target)
val text by remember { derivedStateOf { animated.toInt().toString() } }
val animated by animateFloatAsState(target)
val text = animated.toInt().toString()
val animatedState = animateFloatAsState(target)
val text by remember { derivedStateOf { animatedState.value.toInt().toString() } }
Object Allocation in Composition
Box(modifier = Modifier.padding(16.dp).clip(RoundedCornerShape(8.dp)))
val cardModifier = remember { Modifier.padding(16.dp).clip(RoundedCornerShape(8.dp)) }
Box(modifier = cardModifier)
3. Stability Checklist
| Type | Stable? | Fix |
|---|
Primitives (Int, String, Boolean, Float) | ✅ Yes | — |
data class with only stable fields | ✅ Yes | Ensure all fields stable |
List, Map, Set | ❌ No | Use ImmutableList / @Immutable |
Classes with var | ❌ No | Add @Stable if contractually stable |
Lambda {} | ❌ No | remember { } when capturing mutable state |
| Enum, sealed class | ✅ Yes | — |
4. Guide to Profiling
When code review is inconclusive, collect runtime data:
Layout Inspector (Android Studio)
- Run app in debug mode
- Open Layout Inspector → connect to process
- Enable Show Recomposition Counts
- Interact with the slow screen
- Identify composables with high recomposition counts
⚠️ Debug builds have overhead. Use for counting recompositions, not absolute timing.
Perfetto / System Trace
- Build a release build (R8 enabled, no debug overhead)
- Open Android Studio → Profiler → CPU → Record
- Choose System Trace or export to Perfetto
- Look for: jank frames (>16ms), long Choreographer frames, slow
measure/layout/draw
Macrobenchmark
For repeatable, CI-comparable metrics:
@Test
fun scrollBenchmark() = benchmarkRule.measureRepeated(
packageName = "com.example.myapp",
metrics = listOf(FrameTimingMetric()),
startupMode = StartupMode.COLD,
) {
pressHome()
startActivityAndWait()
device.findObject(By.res("list")).scroll(Direction.DOWN, 10)
}
5. Output Format
Report findings as:
## Compose Performance Report
### Summary
- X recomposition storms detected
- Y unstable types found
- Z missing `remember` blocks
### Findings (ordered by impact)
#### 1. [High] Unstable lambda in `ItemList`
- File: src/.../ItemList.kt:42
- Issue: New lambda created every recomposition
- Fix: Wrap in `remember(item.id) { { ... } }`
#### 2. [Medium] Missing key in LazyColumn
...
### Estimated Impact
- Before: ~30 recompositions/interaction
- After (estimated): ~5 recompositions/interaction
References