Zygote
Hands-on labsintermediate

Lab 12 — Jank

A janky frame is one that missed its deadline. Learn to see that in a trace, use FrameTimeline to attribute it, and tell the difference between work on the main thread, work on the RenderThread and time spent not scheduled at all.

5 minAPI 1–37 · 17verified 2026-10-27

Jank is not "the app is slow". It is specific: a frame arrived after its deadline, so the display repeated the previous one. That definition is testable, and once you can see it in a trace you can attribute it to a thread.

TL;DR#

  • A frame has a deadline, determined by the refresh rate: about 16.67 ms at 60 Hz, 11.11 ms at 90 Hz, 8.33 ms at 120 Hz.
  • FrameTimeline is the Perfetto track that records expected versus actual frames and labels the jank type.
  • Three causes dominate: main-thread work, RenderThread work, and not being scheduled.
  • gfxinfo reports aggregate frame statistics from the framework, which is a quick check before a trace.
  • One janky frame is not a problem. The pattern is: which frame, how often, and what was running.

Mental model#

Think of a conveyor belt that must deliver one part every 16 milliseconds.

If a part arrives late, the belt does not wait: it repeats the previous part. The customer sees a stutter. The question is never "why was it late?" in the abstract; it is "which station held the part, and for how long?".

The stations are the main thread, the RenderThread, the compositor and the CPU scheduler. Each can hold the part, and the trace shows which one did.

Where the analogy breaks#

A conveyor belt has one part at a time. Android pipelines frames: the main thread produces display lists for frame N while the RenderThread works on frame N−1 and the compositor works on frame N−2. That pipelining is why a slow main thread does not always cause jank immediately: the pipeline absorbs one slow frame and janks on the second.

Deep dive#

The budget#

A frame's budget is the refresh interval. At 60 Hz it is 16.67 ms; at 90 Hz, 11.11 ms; at 120 Hz, 8.33 ms. The budget is for the whole pipeline, not for one thread, and the phases overlap.

The practical consequence: an app that uses 10 ms of main-thread time at 60 Hz has 6 ms of headroom, and the same app at 120 Hz is already over budget. That is why "it was smooth on my phone" is not a portable claim.

gfxinfo: the quick check#

adb shell dumpsys gfxinfo <package>

The output includes aggregate frame statistics: total frames, janky frames, percentiles and the histogram. framestats adds per-frame timing for the most recent frames.

adb shell dumpsys gfxinfo <package> framestats

gfxinfo is a sample of recent frames, not a continuous record. It is the right first question ("is this actually janky?") and the wrong tool for attribution.

FrameTimeline#

The FrameTimeline track in Perfetto records, for each frame, when it was expected and when it was actually presented. The two tables are expected_frame_timeline_slice and actual_frame_timeline_slice, and the actual table carries the jank classification.

The useful columns on the actual table include ts, dur, jank_type, present_type, on_time_finish and layer_name.1 The jank_type is a string describing which stage was late, which is exactly the attribution you want.

In the UI, the FrameTimeline track appears under the app's process. A red or highlighted frame is a janky one; selecting it shows the type.

The three causes#

Main-thread work. The main thread slice for the frame is longer than the budget. Common causes: inflation, layout, a synchronous binder call, or a long-running callback on the main thread. In the trace, the main thread's frame slice is wide and the RenderThread starts late.

RenderThread work. The main thread finished in time, but the RenderThread's work is long: complex drawing, a large layer, a shader compilation, or a texture upload. In the trace, the RenderThread slice is wide and the frame is late even though the main thread looks fine.

Not scheduled. Both threads had work to do but were not running. The thread state track shows "runnable" without a running interval. Causes: too many runnable threads, a CPU-bound background job, thermal throttling, or a low-priority thread being starved.

Reading a janky frame#

A reproducible method:

  1. Find the janky frame in FrameTimeline.
  2. Look at the main thread at that time. Is there a wide slice? Which one?
  3. Look at the RenderThread. Did it start on time, and how long did it run?
  4. Look at the thread state. Was either thread runnable but not running?
  5. Look at the CPU tracks. Were the cores busy with something else?

The answer is usually one of the five, and the first three steps eliminate most of the space.

Walkthrough#

  1. Reset the counters

    adb shell dumpsys gfxinfo com.android.settings reset

    You should seeNo output.
  2. Exercise the app

    Interact with the app, then return to the shell.

    You should seeScroll or navigate for a few seconds.
  3. Read the summary

    adb shell dumpsys gfxinfo com.android.settings

    You should seeTotal frames, janky frames and percentiles.
  4. Record a trace while scrolling

    Record with the lab 10 config, scroll during the recording, then pull and open.

    You should seeA trace containing gfx and view slices.
  5. Open FrameTimeline

    Find the app process, expand it, and select a janky frame.

    You should seeA track with expected and actual frames.
Frame statistics
adb shell dumpsys gfxinfo com.android.settings reset
adb shell dumpsys gfxinfo com.android.settings
adb shell dumpsys gfxinfo com.android.settings framestats | head -40
Example output
Illustrative output — values differ per device, app and run.

Challenges#

Challenge 1 — Compute the budget#

A device runs at 90 Hz. How long does a frame have, and how much of that is left if the main thread uses 6 ms?

Reveal the answer

At 90 Hz the interval is 1000/90 ≈ 11.11 ms. If the main thread uses 6 ms, about 5.11 ms remain for the rest of the pipeline — but the phases overlap, so the real constraint is the longest path through the pipeline, not the sum. A 6 ms main thread is comfortable only if the RenderThread and composition fit in the remaining time.

Challenge 2 — Distinguish a slow frame from a starved one#

Two frames both take 30 ms. How do you tell which is which?

Reveal the answer

Look at the thread state. If the thread was running for most of the 30 ms, the work is the cause. If it was runnable but not running for a large part of it, scheduling is the cause. The slice duration alone cannot distinguish them; the thread state track can.

Challenge 3 — Attribute a jank to a stage#

Find a janky frame in FrameTimeline and state whether it was main thread, RenderThread or scheduling.

Reveal the answer

The jank_type on the actual frame gives the platform's classification. Confirm it by looking at the tracks: a main-thread jank has a long main-thread slice ending after the deadline; a RenderThread jank has a long RenderThread slice; a scheduling jank has runnable time without running time on the critical thread. If the classification and the tracks disagree, trust the tracks and investigate why the classifier saw it differently.

What you learned#

You can decide whether an app is janky, find a specific late frame, and attribute it to a stage. That is enough to stop guessing about smoothness.

Next#

Lab 13 — Binder and lock contention looks at the waits that make a thread sleep.

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsPerfetto documentationperfetto.devaccessed 2026-10-27↩
  2. docsการแสดงผลช้า | App qualitydeveloper.android.comaccessed 2026-10-27↩
  3. docsOverview of system tracingdeveloper.android.comaccessed 2026-10-27↩
  4. docsAndroid tracing with Perfettoperfetto.devaccessed 2026-10-27↩
  5. docsdumpsysdeveloper.android.comaccessed 2026-10-27↩
  6. docsMacrobenchmark overviewdeveloper.android.comaccessed 2026-10-27↩