Every frame your app renders is measured, and nothing needs wiring up for it: Picto.setup
installs the frame-timing hook alongside the error hooks, and it reports on whatever session happens to be recording at the time.
What comes back answers two different questions — how many frames missed their budget, and
when the app was stuttering — and those two answers travel by different routes. That split is the whole subject of this page, because it is why a session can honestly report
18 slow while producing not a single frozen-frame row.
What is measured#
One number per frame: build time plus raster time, the frame's own cost.
Deliberately not FrameTiming.totalSpan, which also counts the vsync wait — time the frame spent queued rather than time your app spent working. A number your code cannot act on is not worth classifying.
That sum is compared against two thresholds:
| Slow | build + raster ≥ 16.7ms | One frame interval at 60fps (1000 / 60). |
| Frozen | build + raster ≥ 700ms | Long enough that the user experienced a hang, not a stutter. |
Both numbers are Sentry Mobile Vitals' and Firebase Performance Monitoring's own definitions, taken as-is. They are not configurable, and that is on purpose — an industry-standard threshold that everyone measures against is more useful than one you tuned until your dashboard looked calm.
A frozen frame is also a slow frame. It is not a separate category, it is the same measurement further along.
Frames Flutter's engine did not render are never seen here at all. An interaction that only touches a native map or a WebView schedules no Flutter frames, so a platform view legitimately owning the screen for a while is not reported as jank.
The two places jank lands#
This is the part worth reading slowly.
A frozen frame gets a row of its own. It is written to the jank_events table, correlated to the session by id, carrying its
t_ms and the build_ms/raster_ms split behind it. That is what lets the dashboard list and
alert on freezes across every session without decoding a single recording. It also gets a
frameJank tag written into the recording itself, so it appears in the replay viewer's own event list.
A slow-but-not-frozen frame gets no row, and never will. A real session can legitimately produce thousands of them — a single stuttery scroll is dozens — and one row per frame over 16.7ms would be a table of noise and a payload to match. A frozen frame is rare enough to be a genuine per-occurrence signal; a slow one is not.
So slow frames live in exactly one place: the recording's own upload metadata, under frameJank. Both as counts, and — this is the newer half — as coalesced
bursts that say when.
Reading metadata.frameJank#
Four keys, three of them always present:
| Key | What it is |
|---|---|
total_frames | Every frame rendered, slow or not. |
slow_frames |
Frames at or over 16.7ms — frozen ones included. |
frozen_frames | Frames at or over 700ms. |
slow_spans | Where the slow frames were. Absent when there were none. |
slow_frames including frozen_frames is the trap in that table. A session reporting
312 slow · 3 frozen had 312 bad frames, of which 3 were catastrophic — not 315. The dashboard renders both numbers exactly as the SDK reported them rather than subtracting one from the other, so what you read matches what was sent.
A span is a burst, not a frame#
Each entry in slow_spans describes one run of consecutive slow frames:
| Field | What it is |
|---|---|
t_ms | The burst's first slow frame. |
end_ms |
The burst's last slow frame's t_ms plus that frame's own duration. |
frames | Slow frames in the burst, frozen ones included. |
frozen |
How many of those also crossed 700ms. 0 for an ordinary stutter. |
worst_ms | The largest build+raster total in the burst. |
end_ms is defined that way for a reason worth knowing before you draw anything with it:
a span's width is the stuttering itself, not the interval between two frame starts. A burst whose last frame took 400ms extends 400ms past that frame's start, because that is when the app actually became responsive again.
Consecutive slow frames within 100ms of each other — start to start — belong to the same burst. That is about six frame intervals at 60fps, so a sustained jank storm arriving back-to-back coalesces into one span, while a stutter on scroll-start and a separate one on scroll-end stay the two events the user actually felt. Unlike the two thresholds above, 100ms has no industry number behind it; it is a presentation choice, and the cap below moves it at runtime anyway.
The cap widens, it does not truncate#
One segment carries at most 200 spans.
Past that, the SDK does not drop the tail. It doubles the coalescing gap and re-merges adjacent bursts, repeating until the list fits. Merging is associative on all five fields — earliest start, latest end, summed
frames, summed frozen, largest worst_ms — so an app that janked continuously for an hour arrives as a coarser picture that is still a
true one. The frame counts still add up to what happened, and no burst disappears.
Truncating instead would have been cheaper and would have quietly claimed a janky session went smooth halfway through.
Two things that will catch you out#
slow_spans is absent, not empty. A segment with no slow frames omits the key entirely. That is deliberate: it means a reader can tell "this session did not stutter" from "this recording came from an SDK that could not have told you", which an empty list would have flattened into one answer.
Every value here is per-segment. frameJank describes one upload and nothing else — a fresh recorder is created for each segment, and the timestamps inside
slow_spans are on that segment's own clock, starting at zero. A session that was backgrounded and resumed has one
frameJank per sequence, and reading a single row's counts as the session's totals will under-report a long session badly. See
Sessions & Segments for why one journey arrives as several uploads.
The two halves have different lifetimes. Frozen frames are rows in their own table and outlive the recording; slow_spans and the counts live in the recording's own metadata and are deleted with it after 30 days. See Recording Retention.
Where you actually see this#
Two places in the replay viewer, and they are now describing the same session:
-
The session card's
Framesrow —8,412 · 312 slow · 3 frozen, with the jank tail in amber and omitted entirely when nothing was dropped. It sums every segment'sframeJank, so it reports the whole session rather than whichever upload you opened. -
The timeline's Slow Frames lane — a bar per burst, positioned and sized by
t_ms/end_ms. A burst that contains a frozen frame is drawn taller than an ordinary stutter, in the same color: a freeze is a worse slow frame, not a different kind of problem. See Watching a Replay.
What recording costs your frames#
Fair question to ask a page about frame budgets: picto is measuring every frame, and picto is also recording every frame. Does the recording show up in its own numbers?
Two of picto's per-frame jobs are proportional to what your app draws — intercepting the canvas, and walking the layer tree — so there is no single figure for them. The third one is bounded and worth stating, because it runs on
every composited frame whether or not a session is being recorded: picto stands in front of
SceneBuilder to read where the compositor actually placed each piece of content, which is what keeps popups, followers and blurred or tinted subtrees in the right place in a replay.
Measured on a Material screen — app bar, a scrolling list of forty cards, rounded clips, opacity, a colour filter and a blur, 112 scene operations nested 14 deep:
| added per composited frame | share of a 16.7ms frame | |
|---|---|---|
| A frame that rebuilds the whole tree | 19–25 µs median, 29–46 µs at p90 | ~0.15% |
| A steady-state frame | 11–12 µs median, 19–24 µs at p90 | ~0.07% |
Steady state is the case that matters, because it is where an app spends nearly all of its time: once a subtree stops changing, Flutter hands it back to the compositor by handle rather than describing it again, and picto replays what it already knows about that subtree instead of re-deriving it.
Read those as an upper bound, not a benchmark result for your app. They come from packages/picto/benchmark/scene_capture_overhead_test.dart in picto's own repo, run under flutter test — which executes the Dart VM in JIT mode on a desktop, where allocation and dispatch both cost more than they do in an AOT-compiled release build. They are not a device measurement, and a phone's slower core will move the microseconds while leaving the share of the budget in roughly the same place.
The one shape that is not cheap is a very deeply nested compositing tree. Cost grows faster than the operation count, because closing a layer copies what that layer contained — so content nested
n layers deep is copied n times. At the depth ordinary Material UI produces, this is invisible: the workload above reaches 14, and stays inside a fifth of a percent of the budget. A synthetic scene nested 256 deep pushes it to roughly five times the compositor's own cost for the same content. If your app is generating compositing depth in the hundreds, that is worth knowing for reasons that have nothing to do with picto.