skip to content

How can a Flutter app measure its frame times in release builds with SchedulerBinding.addTimingsCallback, and what do FrameTiming's durations mean?

level: middleimportance: should knowfreq 28%

answer

  1. works in release, unlike the timeline
  2. batched lists, first frame immediately
  3. buildDuration vs rasterDuration
  4. totalSpan for latency, vsyncOverhead
  5. prefer it over onReportTimings

basics

~10 s

SchedulerBinding.instance.addTimingsCallback receives batches of FrameTiming objects, even in release builds. buildDuration is UI-thread time, rasterDuration is raster-thread time, and totalSpan runs from vsync to raster finish. Compare each against the frame budget.

solid answer

~40 s

`SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) { ... })` gets frame timings from the engine **in release mode too**, which the timeline cannot do. The engine batches them: roughly once a second in release and every 100 ms in debug and profile, with the first frame sent at once. Each `FrameTiming` has `buildDuration` (UI thread, from build start until the scene is handed to the view), `rasterDuration` (raster thread), `vsyncOverhead` (vsync signal to build start), and `totalSpan` (vsync to raster finish). If `buildDuration` or `rasterDuration` exceeds the budget, a frame was missed. If `totalSpan` exceeds it, latency was high, even when no frame was missed. Prefer `addTimingsCallback` over `PlatformDispatcher.onReportTimings`, which allows only one callback. Remove your callback with `removeTimingsCallback`, and aggregate numbers before sending them anywhere.

code

dart · 22 lines
dart
import 'package:flutter/scheduler.dart';

class JankCounter {
  JankCounter(this.budget);

  final Duration budget;
  int frames = 0;
  int slowBuild = 0;
  int slowRaster = 0;

  void _onTimings(List<FrameTiming> timings) {
    for (final FrameTiming t in timings) {
      frames++;
      if (t.buildDuration > budget) slowBuild++;
      if (t.rasterDuration > budget) slowRaster++;
    }
  }

  void start() => SchedulerBinding.instance.addTimingsCallback(_onTimings);

  void stop() => SchedulerBinding.instance.removeTimingsCallback(_onTimings);
}

go deeper

for a junior

Know that Flutter can report per-frame build and raster times to your code, even in release builds.

for a middle

Explain buildDuration, rasterDuration, vsyncOverhead and totalSpan, and how batching changes the way you write the callback.

for a senior

Separate missed frames from latency, pick budgets from the real refresh rate, and aggregate data cheaply on the device.

for a principal

Decide which frame metrics the team tracks from the field, how they are segmented, and what thresholds count as a regression.

## Why a runtime API exists DevTools and the timeline only work in debug and profile builds. You cannot attach them to the phones of real users. Flutter therefore reports frame timing to Dart code in **every** build mode, including release, through **`FrameTiming`** objects. When nobody listens, the engine does not collect them, so the cost is close to zero. When a listener is registered, the source describes the overhead as roughly 0.01 % CPU, measured on an older phone. ## Registering a callback - **`SchedulerBinding.instance.addTimingsCallback(callback)`** adds a `TimingsCallback`, a `void Function(List<FrameTiming> timings)`. - **`removeTimingsCallback(callback)`** removes it. Adding the same callback twice makes it run twice. - Underneath, the binding uses **`PlatformDispatcher.onReportTimings`**, which holds only **one** callback. The scheduler API exists so several libraries can listen at once, so prefer it. - In debug and profile builds the scheduler itself registers a timings callback to feed the timeline. ## Batching The engine does not call you once per frame: 1. The **first** frame's timing is sent **immediately**. 2. After that, timings are batched into a list sorted from earliest to latest. 3. A batch arrives within about **1 second** in release, or about **100 ms** in debug and profile, even if no later frames join it. So your callback should be cheap, aggregate rather than react per frame, and never assume a fixed batch size. ## What each value means | Getter | Measured between | Tells you | |---|---|---| | `buildDuration` | `buildStart` and `buildFinish` on the UI thread (finish is when the scene is handed to the view) | Dart work for the frame: animate, build, layout, paint recording | | `rasterDuration` | `rasterStart` and `rasterFinish` on the raster thread | drawing cost of the scene | | `vsyncOverhead` | `vsyncStart` and `buildStart` | delay between the vsync signal and starting to build | | `totalSpan` | `vsyncStart` and `rasterFinish` | end-to-end latency of the frame | The raw timestamps are also available through `timestampInMicroseconds(FramePhase phase)`, with phases `vsyncStart`, `buildStart`, `buildFinish`, `rasterStart`, `rasterFinish` and `rasterFinishWallTime`. `frameNumber` and raster-cache counters such as `layerCacheCount` are there too. ## Missed frames versus latency The scheduler's doc separates two failures: - **Missed frame.** `buildDuration` **or** `rasterDuration` exceeds the budget (`1000 / X` ms for an X Hz display, about 16 ms at 60 Hz and 8 ms at 120 Hz). That frame could not be shown on time. - **High latency.** `totalSpan` exceeds the budget while each part fits. Because the UI and raster threads are pipelined, animations still look smooth, but touch input feels sluggish. ## A worked reading On a 120 Hz device the budget is about 8.3 ms. Suppose one batch holds three frames: 1. `buildDuration` 5 ms, `rasterDuration` 7 ms: both fit, so the frame was on time. 2. `buildDuration` 9 ms, `rasterDuration` 4 ms: the UI thread missed, so this is a Dart-side problem. 3. `buildDuration` 6 ms, `rasterDuration` 6 ms, `totalSpan` 13 ms: no miss, but the frame reached the screen more than one refresh after its vsync, which is latency the user feels while dragging. The same three frames on a 60 Hz device would all count as on time for missed frames. That is why the budget must come from the real refresh rate. ## Using it in production - **Aggregate on the device.** Count frames over budget per thread, and keep percentiles of each duration per screen or per session. Do not send raw lists. - **Use the device's refresh rate** for the budget, not a hard-coded 16 ms. A 120 Hz phone at 12 ms per frame is janky. - **Tag with context**, such as the route name and device class, so a regression can be located. - **Keep the callback cheap.** It runs on the UI isolate, and heavy work there adds to the very numbers you are measuring.

  • Why does the callback receive a list instead of one FrameTiming per frame?
    To keep overhead low in release builds, the engine batches timings and sends them together. The list is sorted with the earliest frame first. The first frame is sent immediately, and later ones arrive within about a second in release, or 100 ms in debug and profile, even if no more frames follow.
  • Why prefer SchedulerBinding.addTimingsCallback over setting PlatformDispatcher.onReportTimings?
    `onReportTimings` holds a single callback, so two libraries setting it would overwrite each other. The binding's API keeps a list of callbacks, registers with the dispatcher only while at least one exists, and lets each be removed independently with `removeTimingsCallback`.

saying these in an interview costs you the question

  • Frame timings are only available with DevTools attached
  • FrameTiming callbacks fire once per frame, synchronously
  • buildDuration plus rasterDuration over 16 ms always means a dropped frame
  • totalSpan is the time the UI thread spent building
  • Setting onReportTimings directly is the recommended API for libraries