---
title: "Fixing Android Jank You Can't See with Systrace and Perfetto"
published: true
description: "A hands-on guide to finding hidden jank in Jetpack Compose apps — RenderThread stalls, recomposition traps, and frame timing fixes that keep 99th-percentile frames under 16ms."
tags: android, kotlin, performance, architecture
canonical_url: https://blog.mvpfactory.co/fixing-android-jank-you-cant-see-with-systrace
---
## What We Will Build
By the end of this workshop, you will have a repeatable profiling workflow that catches the jank your users feel but Android Studio's basic profiler never shows. We will trace the full frame lifecycle in Perfetto, identify RenderThread GPU fence stalls, fix Compose recomposition traps, and tune lazy layout prefetch — the patterns that keep 99th-percentile frame times under 16ms.
## Prerequisites
- Android Studio Hedgehog or later
- A Jetpack Compose project with a scrollable `LazyColumn`
- Perfetto UI ([ui.perfetto.dev](https://ui.perfetto.dev)) or command-line `systrace`
- Compose Compiler plugin configured in your Gradle build
## Step 1: Understand the Frame Lifecycle
Here is the pipeline that matters. The docs do not mention this, but your main thread can finish in 4ms and you still drop the frame if the RenderThread stalls past the vsync boundary.
| Stage | Thread | Jank Risk |
|---|---|---|
| `doFrame` | Main | Recomposition storms |
| `syncFrameState` | Main → Render | Large draw ops |
| `issueDrawCommands` | RenderThread | Shader compilation |
| `GPU Completion Fence` | RenderThread | Texture uploads, overdraw |
| `
` | SurfaceFlinger | Missed vsync deadline |
Perfetto shows RenderThread stalls as a gap between `DrawFrame` end and the next `doFrame` start on the RenderThread track. That is where your invisible jank lives.
## Step 2: Catch RenderThread Stalls
The pattern I use in every project to catch first-frame shader compilation: filter your Perfetto trace on the RenderThread track and look for `GrGLGpu::compile` slices. These show up as single 40-80ms spikes that are completely invisible in CPU profiling. They happen once, look like noise, and teams ignore them. Don't.
Pre-warm shader paths during splash screen transitions and keep your `RenderNode` tree flat. Every nested `graphicsLayer` creates a new RenderNode with its own display list.
## Step 3: Fix Compose Recomposition Traps
Two patterns dominate. Let me show you both.
**Unstable lambda captures** — here is the gotcha that will save you hours:
kotlin
// BAD: New lambda instance every recomposition
@Composable
fun ItemRow(item: Item, viewModel: MyViewModel) {
Button(onClick = { viewModel.onItemClick(item.id) }) {
Text(item.name)
}
}
// GOOD: Stable reference via remember
@Composable
fun ItemRow(item: Item, viewModel: MyViewModel) {
val onClick = remember(item.id) { { viewModel.onItemClick(item.id) } }
Button(onClick = onClick) {
Text(item.name)
}
}
In a `LazyColumn` with 50 visible items, unstable lambdas trigger 50 unnecessary recompositions per frame during scroll.
**Missing stability annotations** — classes from external modules are assumed unstable by default:
kotlin
@Immutable
data class UiItem(val id: String, val title: String, val imageUrl: String)
Run the Compose Compiler metrics report (`-P plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=...`) to audit unstable classes. I have seen scroll jank drop by 60% after stabilizing just three key model classes. Three classes. That is it.
## Step 4: Tune Lazy Layout Prefetch
Here is the minimal setup to get this working. The default prefetch distance works for simple items, but complex compositions blow past the frame budget.
| Strategy | Budget Used | Result |
|---|---|---|
| Default (next item) | ~4-6ms | Good for simple items |
| `LazyLayoutPrefetchStrategy(3)` | ~2ms × 3 spread across frames | Better for complex items |
| Over-prefetch (10+) | Steals from visible frames | Worse, causes visible jank |
The sweet spot is 2-3 items with compositions completing within 3ms each. Measure in Perfetto by filtering to `compose:recompose` slices during scroll.
## Step 5: Batch Display List Operations
kotlin
// BAD: Three separate draw passes
Modifier
.drawBehind { drawRect(backgroundColor) }
.drawBehind { drawRoundRect(borderColor, cornerRadius = 8.dp.toPx()) }
.drawBehind { drawCircle(accentColor, radius = 4.dp.toPx()) }
// GOOD: Single draw pass
Modifier.drawBehind {
drawRect(backgroundColor)
drawRoundRect(borderColor, cornerRadius = 8.dp.toPx())
drawCircle(accentColor, radius = 4.dp.toPx())
}
This reduces RenderNode operations and keeps display list serialization under budget.
## Gotchas
- **Average frame time lies.** Your users feel the 99th percentile. A single 34ms frame in a 300-frame scroll ruins perceived smoothness. Stop looking at averages.
- **First-frame shader compilation is silent.** It shows up once as a 40-80ms RenderThread spike and looks like noise. Pre-warm during splash transitions.
- **Nested `graphicsLayer` calls are expensive.** Each one creates a new RenderNode with its own display list. Flatten aggressively.
- **GPU completion fence stalls over 8ms are your signal.** Filter the Perfetto RenderThread track — that is exactly where invisible jank hides.
## Conclusion
Profile with Perfetto, not just Android Studio. Run the Compose Compiler stability report on every release. Annotate key UI model classes with `@Immutable` or `@Stable`, wrap lambdas in `remember` with proper keys, tune `LazyColumn` prefetch to 2-3 items, and batch draw operations into single modifiers. Your 299 fast frames do not matter if frame 300 stutters.
Top comments (1)
I've seen similar issues with jank in my own apps, especially when dealing with complex composable functions. Using remember to cache stable references has been a lifesaver in those cases. What are some other common pitfalls that can cause jank in Android apps that aren't immediately visible in the UI?