5 min read

Reading a Perfetto Trace: From 'It's Janky' to a Line Number

A repeatable workflow for turning vague jank reports into a specific method: capture a short trace, find the dropped frame, identify which stage blew the frame budget, then read the slice stack — with your own trace sections making your code visible.

AndroidPerformancePerfettoJankProfiling
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Don't ask "what's slow?" Ask "which frame missed its deadline, and what was on the thread when it did?" Capture a short trace around the jank, find red frames in the Frames track, note which stage overran (app work, RenderThread, GPU), then read the widest slice underneath. Add trace() sections to your own code first — otherwise the trace tells you about AOSP, not your app.

Why performance discussions stall

"The app feels janky" is a sensation, not a measurement. Everyone agrees, nobody can point at a cause, and the team starts proposing fixes — a cache here, a remember there — based on intuition. Some of them even help, which is worse, because now you've learned a superstition.

A trace ends that. It's a recording of what every thread actually did, microsecond by microsecond, and it converts an opinion into a specific method on a specific thread.

Step 1: capture a short window

Either Developer Options → System Tracing on device, or from a terminal:

python3 record_android_trace -o trace.perfetto-trace -t 10s \
    -b 64mb sched freq gfx view wm am binder_driver

Reproduce the jank inside that window and stop. Ten seconds is plenty; sixty gives you a haystack you built yourself. The categories matter more than the duration — gfx and view are what populate the frame timeline.

Open the result at ui.perfetto.dev. Nothing is uploaded; it's parsed locally in the browser.

Step 2: find the frame, not the function

Expand your app's process and find the Frames track. Each frame is a box, and janky ones are red. Click one. Perfetto tells you which stage overran the budget, and that single fact removes most of the search space:

  • App / main thread too long — your code. Composition, layout, a synchronous read, work on the wrong dispatcher.
  • RenderThread too long — drawing cost. Overdraw, expensive shaders, large or unclipped bitmaps, heavy Modifier.blur/shadow work.
  • GPU too long — fill-rate bound. Usually too many layers or oversized surfaces.

Most teams begin by optimising app code. Roughly a third of the time the trace says RenderThread, and they'd have spent a week in the wrong file.

Step 3: read the slice stack

With a janky frame selected, expand the main thread underneath it. You get a flame-chart of nested slices — Choreographer#doFrame, then traversal, then measure/layout/draw, then whatever your app was doing.

The widest slice at each level is where the time went. Keep descending until the name stops being a framework method and starts being yours. Two shortcuts that pay off:

  • Select a slice and press m to mark it — the duration appears, and you can compare against the 16.6ms budget (or 8.3ms at 120Hz) directly.
  • Use the Slices aggregation tab over the selected window to see total time by slice name. A method taking 0.4ms but called 200 times looks like nothing in the flame chart and dominates the aggregate.

Step 4: make your own code visible

By default, a trace is full of framework slices and almost none of yours. Fix that:

import androidx.tracing.trace

fun mapFeed(items: List<FeedDto>): List<FeedUiModel> = trace("HomeViewModel.mapFeed") {
    items.map(::toUiModel)
}

androidx.tracing is cheap when tracing is off, so a handful of permanent sections at meaningful boundaries — repository fetch, mapping, expensive composables — is a reasonable thing to ship. In Compose, Modifier chains and recomposition already appear as slices if you enable the compose.ui category, but your business logic will not.

Three well-placed sections turn a trace from "AOSP's story" into "my app's story", and that's usually the difference between a two-hour investigation and a two-day one.

What this looks like in practice

A concrete example from a real session: a list scrolled badly. The intuition on the team was "too many recompositions". The trace said RenderThread, not main thread — every frame spent 14ms drawing. Underneath: a shadow on each list item, forcing an offscreen layer per row. Replacing it with a border took RenderThread to 3ms. No recomposition work was needed, and the recomposition theory would have cost days.

That's the value. Not that tracing makes you faster at optimising — it makes you optimise the right thing.

The habit worth building

Before any performance work, get a trace. Before proposing a fix, be able to point at the frame that missed and the slice that caused it. And after the fix, capture again and compare, because "it feels better" is exactly the kind of evidence that got you here.

Keep reading