skip to content

In Flutter, what happens between one vsync signal and the next rendered frame, and in what order do the phases run?

level: middleimportance: must knowfreq 50%

answer

  1. two engine callbacks per frame
  2. handleBeginFrame, then handleDrawFrame
  3. transient, microtasks, persistent, post-frame
  4. build, layout, compositing bits, paint
  5. layer tree becomes a Scene

basics

~20 s

Each frame, SchedulerBinding runs transient callbacks (animation ticks), then their microtasks, then persistent callbacks that rebuild dirty elements, lay out, paint into a layer tree and composite it, then post-frame callbacks. The raster thread draws the resulting scene.

solid answer

~40 s

A frame is only produced when something asked for one (`setState`, a ticker, `markNeedsLayout`). At the next vsync the engine calls `SchedulerBinding.handleBeginFrame`, which runs the **transient** callbacks (animation tickers), then the microtasks they queued. It then calls `handleDrawFrame`, which runs the **persistent** callbacks; the main one is `WidgetsBinding.drawFrame`: `buildScope` rebuilds dirty elements, then `PipelineOwner.flushLayout`, `flushCompositingBits` and `flushPaint`, then `compositeFrame` turns the layer tree into a `Scene` and hands it to the engine, then `flushSemantics`, then `finalizeTree` unmounts removed elements (calling `State.dispose`). Last come the **post-frame** callbacks, once each. The raster thread then rasterizes the scene while the UI side can start the next frame.

go deeper

for a junior

Recall the order: animations, build, layout, paint, then post-frame callbacks. Know that nothing is laid out until the frame after setState is called.

for a middle

Name the SchedulerPhase values and the PipelineOwner flush calls in order, and explain why ticking animations before build keeps values in sync within one frame.

for a senior

Use the phase order to explain real bugs: stale sizes read right after setState, setState during build errors, and post-frame callbacks outliving a disposed State.

for a principal

Frame the pipeline as two budgets on two threads, and set team rules about which work may run in build, which in post-frame callbacks, and which belongs off the frame entirely.

## Frames are requested, not continuous Flutter does not redraw on every vsync. A frame is produced only when something has asked for one: `setState` marks an element dirty and the widgets binding calls `ensureVisualUpdate`, a render object calls `markNeedsLayout` or `markNeedsPaint` and its `PipelineOwner` calls `requestVisualUpdate`, or a ticker registers a transient callback. Any of these ends in `SchedulerBinding.scheduleFrame`, which asks the engine to call back at the next **vsync** (the display's refresh signal). An app that changes nothing produces no frames. ## The two halves of a frame The engine calls into the framework twice per frame, and `SchedulerBinding.schedulerPhase` reports where it is: | `SchedulerPhase` | Entered by | What runs | |---|---|---| | `transientCallbacks` | `handleBeginFrame` | callbacks from `scheduleFrameCallback`, mainly tickers driving animations | | `midFrameMicrotasks` | end of `handleBeginFrame` | microtasks those callbacks queued, such as completed animation futures | | `persistentCallbacks` | `handleDrawFrame` | callbacks from `addPersistentFrameCallback`; the rendering pipeline lives here | | `postFrameCallbacks` | `handleDrawFrame` | callbacks from `addPostFrameCallback`, each once | | `idle` | between frames | timers, input events, futures, streams | Running animations first matters: a ticker updates its value **before** build, so the widgets that read it rebuild with the new value in the same frame. ## Inside the persistent phase, step by step The most important persistent callback is `WidgetsBinding.drawFrame`, which extends `RendererBinding.drawFrame`. In order: 1. **Build.** `BuildOwner.buildScope` rebuilds every dirty `Element` (the ones `setState` or an inherited-widget change marked), producing updated widget configurations and updating render objects. 2. **Layout.** `PipelineOwner.flushLayout` lays out every render object that called `markNeedsLayout`, processing the shallowest dirty nodes first so a parent's layout can settle its children in one pass. 3. **Compositing bits.** `flushCompositingBits` recomputes which render objects need their own compositing layer. 4. **Paint.** `flushPaint` repaints dirty render objects, deepest first, recording drawing commands into **layers**. The result is a **layer tree**: picture layers holding recorded drawing, and container layers (offset, transform, opacity, clip) arranging them. 5. **Composite.** Each `RenderView.compositeFrame` builds a `ui.Scene` from the layer tree and hands it to the engine with `FlutterView.render`. The source comment says it plainly: this sends the bits to the GPU. 6. **Semantics.** `flushSemantics` updates the semantics tree the platform's accessibility services read. 7. **Finalize.** `BuildOwner.finalizeTree` unmounts elements that were removed this frame; this is where `State.dispose` runs for widgets that left the tree. After `drawFrame` returns, `handleDrawFrame` switches to `postFrameCallbacks` and runs every callback registered with `addPostFrameCallback`, in registration order, exactly once. Then the phase returns to `idle`. ## Where the raster thread comes in Everything above runs on the thread that executes the root isolate's Dart code, historically called the **UI thread**. What it produces is not pixels but a description: the `Scene`. The engine's **raster thread** takes that scene and draws it with the renderer (Impeller on iOS, Android API 29+ and desktop) through the GPU. While the raster thread is busy with frame N, the UI side can already build frame N+1, so a frame has two budgets, one per thread. ## Why the order matters in practice - **Sizes exist only after layout.** Reading a `RenderBox.size` or a scroll extent right after `setState` returns the old value, because build and layout have not run yet. A post-frame callback sees the new layout. - **Build must not dirty what was already built.** Marking an element dirty in the middle of the build phase is usually an error (`setState() or markNeedsBuild() called during build.`), because the framework may already have built it this frame. - **Layout and paint must not schedule builds.** Calling `setState` from a layout or paint callback fails in debug mode with `Build scheduled during frame.`; the framework's hint points to `LayoutBuilder` for size-dependent builds, or to a post-frame callback when a one-frame delay is intended. - **Disposal happens before post-frame callbacks.** A post-frame callback registered by a `State` can run after that `State` was disposed in the same frame, which is why such callbacks check `mounted`. - **A change made in a post-frame callback lands in the next frame.** In that phase `ensureVisualUpdate` schedules a fresh frame rather than joining the one that is finishing. ## Misconceptions to avoid - Layout is not before build: build produces the render objects that layout sizes. - Paint does not measure or position; it records drawing for objects already laid out. - The UI thread does not produce pixels; it produces a layer tree for the raster thread. - Post-frame callbacks are not a frame phase you can skip to: they run only when a frame actually runs. - Semantics is not a separate frame; it is updated inside the same persistent callback, after compositing.

  • Does Flutter produce a frame on every vsync even when nothing changes?
    No. `SchedulerBinding.scheduleFrame` is called only when something asks: `setState`, a render object marking itself dirty, or a ticker registering a transient callback. With nothing dirty the scheduler stays `idle` and no frame work runs, which is why a post-frame callback registered on an idle app waits for whatever frame comes next.
  • Why do animation tickers run before the build phase rather than after it?
    Because the build phase reads animation values. Tickers fire in the `transientCallbacks` phase, their listeners mark widgets dirty, and `buildScope` then rebuilds those widgets with the new value in the same frame. If ticking came after build, every animation would show a value one frame late.
  • In which part of the frame does State.dispose run for a widget removed by this frame's build?
    In `BuildOwner.finalizeTree`, the last step of `WidgetsBinding.drawFrame`, after layout, paint, compositing and semantics but before post-frame callbacks. Elements removed during build are deactivated first and unmounted there if nothing reinserted them, so a post-frame callback can run after its `State` is already gone.

A frame is like a newspaper print run: first late wire updates arrive (animations), editors rewrite the stories that changed (build), layout fits them on the page, typesetters produce the plates (paint into layers), and the plates go to a separate press room (the raster thread). Notes for tomorrow's edition are handled only after the plates have left (post-frame callbacks).

saying these in an interview costs you the question

  • Layout runs before build in each Flutter frame.
  • Flutter repaints every widget on every vsync even when nothing changed.
  • Post-frame callbacks run before layout, so sizes are not ready there.
  • The UI thread rasterizes pixels and hands the GPU a finished bitmap.
  • Paint is the pass where Flutter measures and positions children.
  • Microtasks queued by animation callbacks wait until the frame has finished.