---
title: "Diagnose Android Jank with FrameTimeline API"
published: true
description: "Use Android 12+ FrameTimeline API to pinpoint exact dropped frames in Jetpack Compose — from Perfetto traces to automated CI regression detection with Macrobenchmark."
tags: android, mobile, performance, architecture
canonical_url: https://blog.mvpfactory.co/diagnose-android-jank-with-frametimeline-api
---
## What We Will Build
By the end of this tutorial, you will have a repeatable workflow for diagnosing jank in Jetpack Compose apps using Android 12's FrameTimeline API. You will capture a Perfetto trace, trace a dropped frame back to the exact recomposition or layout pass that broke the 16.6ms budget, and set up a Macrobenchmark test that catches regressions in CI before they ship.
Let me show you a pattern I use in every project — one that turns hours of guessing into a five-minute lookup.
## Prerequisites
- Android 12+ device or emulator (API 31+)
- Android Studio with Perfetto UI access
- A Jetpack Compose project with scrollable content
- Macrobenchmark module configured in your project
## Step 1: Understand How FrameTimeline Connects the Pipeline
FrameTimeline assigns each frame a unique **token** that flows through the entire rendering pipeline:
Choreographer.doFrame() → Compose recomposition → HWUI RenderThread → SurfaceFlinger
For every frame, SurfaceFlinger records `expectedPresentTime`, `actualPresentTime`, `jankType`, and `frameDuration`. When `actualPresentTime` exceeds `expectedPresentTime`, you missed the deadline. The `jankType` field tells you whether your app or the compositor caused it — `AppDeadlineMissed`, `SurfaceFlinger`, `PredictionError`, or `None`.
Here is the minimal setup to get this working.
## Step 2: Capture a Perfetto Trace with FrameTimeline
bash
adb shell perfetto -o /data/misc/perfetto-traces/trace.pb -t 10s \
-c - <<EOF
buffers: { size_kb: 65536 }
data_sources: { config { name: "android.surfaceflinger.frametimeline" } }
data_sources: { config { name: "android.gpu.memory" } }
EOF
Open the trace in Perfetto UI. Look for the `Expected Timeline` vs `Actual Timeline` lanes — they render side-by-side per surface. Red slices are deadline misses.
## Step 3: Trace the Frame Token Back to Its Source
Click a red frame slice. The detail panel shows the frame token, jank classification, and duration breakdown. If `jankType = AppDeadlineMissed`, the bottleneck is in your app process.
Now cross-reference the frame token with Choreographer slices. In the app process tracks, find the matching `Choreographer#doFrame` with the same VSYNC ID. Expand its children — you will see `compose:recomposition`, `measure`, `layout`, and `draw` phases with individual durations.
This is usually where you get your answer. A recomposition triggered by a `derivedStateOf` recalculation or an unbounded `LazyColumn` item composition shows up as an abnormally long child slice.
## Step 4: Check HWUI RenderThread Contention
Even when the main thread finishes under budget, the HWUI `RenderThread` can miss the SurfaceFlinger deadline on its own. Watch for these signatures:
- **Long `syncFrameState` slice** — large display list sync adding 2-8ms
- **`Upload` slices on RenderThread** — GPU texture uploads costing 3-12ms for large bitmaps
- **`compile` slices** — shader compilation hitting 20-80ms on first occurrence
- **`Lock contention` markers** — main thread contention causing 1-5ms stalls
The docs do not mention this, but shader compilation jank during first-launch animations is a particular headache in Compose. I have seen 60ms+ hits from a single shader compile on mid-range devices.
## Step 5: Automate Regression Detection in CI
kotlin
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@test
fun scrollJankMetrics() {
benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()),
iterations = 5,
setupBlock = { pressHome(); startActivityAndWait() }
) {
val list = device.findObject(By.res("item_list"))
list.setGestureMargin(device.displayWidth / 5)
list.fling(Direction.DOWN)
}
}
`FrameTimingMetric` reports `frameDurationCpuMs` and `frameOverrunMs` at P50, P90, P95, and P99. Set your CI gates around these thresholds:
| Metric | Green | Yellow | Red |
|---|---|---|---|
| `frameOverrunMs` P50 | < 0ms | 0-5ms | > 5ms |
| `frameOverrunMs` P99 | < 8ms | 8-16ms | > 16ms |
| `frameDurationCpuMs` P90 | < 12ms | 12-16ms | > 16ms |
Store results in a time-series database and alert on week-over-week P99 regressions exceeding 3ms.
## Gotchas
- **The number one mistake teams make is assuming jank is a GPU problem.** In practice, most frame drops in Compose-based UIs originate on the main thread or during recomposition, not during draw.
- **Do not measure averages.** Averages hide the worst frames, and the worst frames are what users actually feel. Gate CI on `frameOverrunMs` P99, not average frame duration.
- **A clean main thread does not guarantee smooth frames.** Profile the RenderThread separately — shader compilation, texture uploads, and display list sync are independent sources of deadline misses that bite hardest on first launch.
- **Before Android 12, connecting a specific Choreographer callback to a SurfaceFlinger deadline miss meant manually aligning timelines.** FrameTimeline tokens eliminate that pain entirely.
## Conclusion
Here is the gotcha that will save you hours: stop guessing which frame dropped and why. Open Perfetto, find the red `Actual Timeline` slices, trace the frame token back to the exact `Choreographer#doFrame` callback and its child recomposition phases. Then put `FrameTimingMetric` in your CI pipeline with hard P99 thresholds so regressions never ship silently.
FrameTimeline tokens, not averages. Percentiles, not gut feelings. That is how you ship smooth Compose UIs.
Top comments (0)