Build Scheduling and Frames
What this lesson adds
Section titled “What this lesson adds”The Render Pipeline already named the five phases of a frame (build, layout, paint, composite, raster) and the ~16.6ms budget. Async in Dart already proved, with a real ordering test, that Dart’s event loop has a microtask queue that fully drains before the next event-queue task. This lesson does not repeat either proof. It answers the question both leave open: what actually turns “a widget called setState” into “a frame runs,” and in what exact order does that one frame do its work? — straight from the installed Flutter 3.47.5 source (revision 6a19cca564), then proven with a real, passing test that two setState calls in the same synchronous turn produce exactly one build.
SchedulerPhase: the five phases, quoted verbatim
Section titled “SchedulerPhase: the five phases, quoted verbatim”SchedulerPhase is a real enum in the installed source — not a description of Flutter’s scheduling model, the literal type SchedulerBinding.schedulerPhase returns:
// scheduler/binding.dart:160 -- quoted verbatim from the installed 3.47.5 sourceenum SchedulerPhase { /// No frame is being processed. Tasks (scheduled by /// [SchedulerBinding.scheduleTask]), microtasks (scheduled by /// [scheduleMicrotask]), [Timer] callbacks, event handlers (e.g. from user /// input), and other callbacks (e.g. from [Future]s, [Stream]s, and the like) /// may be executing. idle,
/// The transient callbacks (scheduled by /// [SchedulerBinding.scheduleFrameCallback]) are currently executing. /// /// Typically, these callbacks handle updating objects to new animation /// states. transientCallbacks,
/// Microtasks scheduled during the processing of transient callbacks are /// current executing. midFrameMicrotasks,
/// The persistent callbacks (scheduled by /// [SchedulerBinding.addPersistentFrameCallback]) are currently executing. /// /// Typically, this is the build/layout/paint pipeline. persistentCallbacks,
/// The post-frame callbacks (scheduled by /// [SchedulerBinding.addPostFrameCallback]) are currently executing. /// /// Typically, these callbacks handle cleanup and scheduling of work for the /// next frame. postFrameCallbacks,}Two facts fall directly out of this enum that the rest of this lesson proves with real tests: transientCallbacks (where Ticker/AnimationController update values, below) runs before persistentCallbacks (where the actual build/layout/paint pipeline lives) — so an animation’s value for this frame is settled before that frame’s widgets rebuild against it; and postFrameCallbacks is a genuinely separate, later phase from persistentCallbacks — which is exactly why addPostFrameCallback can safely read layout results that don’t exist yet during build(), proven below.
WidgetsBinding.drawFrame: the real order, quoted
Section titled “WidgetsBinding.drawFrame: the real order, quoted”WidgetsBinding.drawFrame’s own doc comment spells out every step of persistentCallbacks for an app using the widgets layer — quoted verbatim, not summarized:
- “The build phase: All the dirty
Elements in the widget tree are rebuilt (seeState.build).” - “The layout phase: All the dirty
RenderObjects in the system are laid out (seeRenderObject.performLayout).” - “The compositing bits phase: The compositing bits on any dirty
RenderObjectobjects are updated.” - “The paint phase: All the dirty
RenderObjects in the system are repainted (seeRenderObject.paint). This generates theLayertree.” - “The compositing phase: The layer tree is turned into a
Sceneand sent to the GPU.” - “The semantics phase: All the dirty
RenderObjects in the system have their semantics updated… This generates theSemanticsNodetree.” - “The finalization phase in the widgets layer: The widgets tree is finalized. This causes
State.disposeto be invoked on any objects that were removed from the widgets tree this frame.” - “The finalization phase in the scheduler layer: After
drawFramereturns,handleDrawFramethen invokes post-frame callbacks (registered withaddPostFrameCallback).”
This is The Render Pipeline’s five phases (build/layout/paint/composite/raster), read straight from the actual scheduling code that runs them, with three steps that lesson didn’t need to name: compositing bits (a real, separate dirty-flag pass between layout and paint), semantics, and the two-part finalization — widget disposal happens inside drawFrame itself, but post-frame callbacks are a genuinely later step, only after drawFrame has returned. That gap between step 7 and step 8 is exactly why addPostFrameCallback is proven below to see real, finished layout that build() cannot.
setState → markNeedsBuild → scheduleBuildFor → scheduleFrame: the real chain
Section titled “setState → markNeedsBuild → scheduleBuildFor → scheduleFrame: the real chain”Four real, separately-verified steps connect one setState() call to a scheduled frame:
flowchart LR s["State.setState(fn)"] --> m["Element.markNeedsBuild()"] m --> b["BuildOwner.scheduleBuildFor(element)"] b --> h["WidgetsBinding._handleBuildScheduled() (onBuildScheduled callback)"] h --> e["SchedulerBinding.ensureVisualUpdate()"] e --> f["SchedulerBinding.scheduleFrame()"] f --> p["PlatformDispatcher.scheduleFrame() (engine calls back onDrawFrame)"]
State.setState(VoidCallback fn)— its own doc comment states plainly: “In practice, thesetStatemethod’s implementation is trivial: it calls the provided callback synchronously, then callsElement.markNeedsBuild.”Element.markNeedsBuild()— the exact coalescing guard this lesson proves below is two real lines in this method:if (dirty) { return; }(line 5399) before_dirty = true; owner!.scheduleBuildFor(this);. A secondmarkNeedsBuild()on an already-dirty element is a no-op.BuildOwner.scheduleBuildFor(Element element)— adds the element to_dirtyElements(proven in The Three Trees) and, the first time in a batch, invokesBuildOwner.onBuildScheduled, whichWidgetsBindingwires to its own_handleBuildScheduledat startup (buildOwner!.onBuildScheduled = _handleBuildScheduled;, binding.dart:477).SchedulerBinding.ensureVisualUpdate(), which_handleBuildScheduledcalls, is whereSchedulerPhase(above) decides what happens next — quoted verbatim: “This has no effect ifschedulerPhaseisSchedulerPhase.transientCallbacksorSchedulerPhase.midFrameMicrotasks… orSchedulerPhase.persistentCallbacks… It will schedule a frame if theschedulerPhaseisSchedulerPhase.idle… orSchedulerPhase.postFrameCallbacks.” Outside of an active frame, this callsscheduleFrame(), which setshasScheduledFrame = trueand asks the engine’sPlatformDispatcherto eventually call backonDrawFrame— the entry point intodrawFrameabove.
Proven: two setState calls in the same turn produce exactly ONE build
Section titled “Proven: two setState calls in the same turn produce exactly ONE build”State.setState’s own doc comment states the design intent directly: “The method is idempotent, there is no benefit to calling it more than once per State per frame.” This is not merely a style recommendation — Element.markNeedsBuild’s if (dirty) { return; } guard (quoted above) makes the second call in the same frame a genuine no-op at the scheduling level, provable with a real build counter:
import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';
int buildCount = 0;
class CounterWidget extends StatefulWidget { const CounterWidget({super.key}); @override CounterWidgetState createState() => CounterWidgetState();}
class CounterWidgetState extends State<CounterWidget> { int value = 0;
// Calls setState() TWICE, synchronously, in the same call stack (the same // microtask/event-handler turn) -- exactly the scenario the coalescing // guard in Element.markNeedsBuild (`if (dirty) { return; }`) exists for. void bumpTwice() { setState(() => value++); setState(() => value++); }
@override Widget build(BuildContext context) { buildCount++; return Text('$value'); }}
double? sizeDuringBuild;bool sizeDuringBuildWasChecked = false;double? sizeInPostFrameCallback;
class SizeProbe extends StatefulWidget { const SizeProbe({super.key}); @override State<SizeProbe> createState() => _SizeProbeState();}
class _SizeProbeState extends State<SizeProbe> { final boxKey = GlobalKey();
@override Widget build(BuildContext context) { // Inside build(): the render tree for THIS frame has not been laid out // yet (layout is a LATER phase of the same frame -- see // WidgetsBinding.drawFrame, quoted above). The box for boxKey does not // exist yet on the very first build, so findRenderObject() is null. sizeDuringBuildWasChecked = true; final renderObject = boxKey.currentContext?.findRenderObject(); sizeDuringBuild = (renderObject is RenderBox && renderObject.hasSize) ? renderObject.size.width : null;
// addPostFrameCallback runs in SchedulerPhase.postFrameCallbacks, which // drawFrame's own doc comment (quoted above, step 8) places AFTER the // layout phase (step 2) and paint (step 4) -- by the time this callback // fires, layout has genuinely happened. SchedulerBinding.instance.addPostFrameCallback((_) { final box = boxKey.currentContext!.findRenderObject() as RenderBox; sizeInPostFrameCallback = box.size.width; });
return Center(child: SizedBox(key: boxKey, width: 123, height: 45, child: Container())); }}import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/frame_probe.dart';
void main() { testWidgets('two setState calls in the same synchronous turn produce ONE build (frame coalescing)', (tester) async { buildCount = 0; final key = GlobalKey<CounterWidgetState>(); await tester.pumpWidget(MaterialApp(home: CounterWidget(key: key))); expect(buildCount, 1, reason: 'the initial mount build');
key.currentState!.bumpTwice(); // calls setState() twice, synchronously await tester.pump(); // flush exactly one frame
expect(buildCount, 2, reason: 'ONE rebuild for both setState calls, not two -- Element.markNeedsBuild\'s "if (dirty) return;" guard'); expect(find.text('2'), findsOneWidget, reason: 'both increments were still applied -- coalescing merges the BUILD, not the state mutations'); });
testWidgets('addPostFrameCallback runs after layout: renderBox.size is available there, not during build', (tester) async { sizeDuringBuild = null; sizeDuringBuildWasChecked = false; sizeInPostFrameCallback = null;
await tester.pumpWidget(const MaterialApp(home: SizeProbe()));
expect(sizeDuringBuildWasChecked, isTrue); expect(sizeDuringBuild, isNull, reason: 'during build(), THIS frame\'s render object does not exist yet -- layout has not run'); expect(sizeInPostFrameCallback, 123.0, reason: 'by the time addPostFrameCallback fires, layout has genuinely happened -- the real, laid-out width is available'); });
testWidgets('WidgetsBinding.instance.scheduleFrame() sets hasScheduledFrame', (tester) async { // Drain any frame already scheduled by pumpWidget's own setup first. await tester.pump(); expect(SchedulerBinding.instance.hasScheduledFrame, isFalse, reason: 'no pending work -- nothing scheduled a frame');
SchedulerBinding.instance.scheduleFrame(); expect(SchedulerBinding.instance.hasScheduledFrame, isTrue, reason: 'scheduleFrame() flips the flag immediately, before the engine actually calls back');
await tester.pump(); expect(SchedulerBinding.instance.hasScheduledFrame, isFalse, reason: 'the flag resets once that frame has actually run'); });}00:00 +0: two setState calls in the same synchronous turn produce ONE build (frame coalescing)00:00 +1: addPostFrameCallback runs after layout: renderBox.size is available there, not during build00:00 +2: WidgetsBinding.instance.scheduleFrame() sets hasScheduledFrame00:00 +3: All tests passed!(trimmed from the real flutter test test/lessons/flutter-internals__build-scheduling-and-frames/ run on this Mac)
Three separate, real results: (1) buildCount going from 1 to 2 — not 3 — after two synchronous setState calls is the direct proof of coalescing; find.text('2') in the same test confirms both state mutations still happened, only the rebuild was merged into one. (2) sizeDuringBuild staying null while sizeInPostFrameCallback comes back as the real 123.0 proves addPostFrameCallback genuinely runs after layout, not merely “later” in some vague sense — SizeProbe’s own build() cannot see its own frame’s layout result, because layout (drawFrame step 2) has not run yet when build() (step 1) is still executing. (3) hasScheduledFrame flipping true immediately on scheduleFrame() and back to false once that frame actually ran is ensureVisualUpdate’s documented behavior, observed directly rather than trusted.
Ticker and vsync: transientCallbacks runs first, proven by the enum order
Section titled “Ticker and vsync: transientCallbacks runs first, proven by the enum order”Every AnimationController (covered in depth, with real intermediate-value proofs, in Animations) is driven by a Ticker, whose own doc comment names exactly what drives it: “SchedulerBinding.scheduleFrameCallback, which drives tickers.” scheduleFrameCallback registers a transient callback — the SchedulerPhase.transientCallbacks phase quoted above, which the enum’s own ordering places before persistentCallbacks (the build/layout/paint pipeline). This is why an AnimationController’s value for a given frame is fully settled — vsync-driven, one tick per real display frame — before any widget that reads animation.value in its build() method runs for that same frame; a “flutter animations are janky” report is a report about persistentCallbacks (or something upstream of it) taking too long, never about the ticker itself skipping a beat.
Jank, precisely
Section titled “Jank, precisely”The Render Pipeline already quantified the budget (~16.6ms at 60fps, ~8.3ms at 120Hz). This lesson’s contribution is exactly where in the pipeline a missed budget comes from: every phase proven above (transientCallbacks → midFrameMicrotasks → persistentCallbacks’s eight steps → postFrameCallbacks) runs on the same thread, synchronously, inside one call to handleDrawFrame. A frame is late when the sum of that work — your build() methods, performLayout(), paint(), and anything a postFrameCallback does before yielding — exceeds the budget. There is no separate thread quietly absorbing a slow build(); the mechanism proven above is the critical path.
Mental Model
Section titled “Mental Model”Think of one frame as a single, strictly-ordered relay race with five runners who never overlap: transientCallbacks (tickers/animations settle their values for this frame), midFrameMicrotasks (any Future/await continuations those callbacks triggered), persistentCallbacks (the eight-step build→layout→…→finalize pipeline, proven above verbatim from drawFrame’s own doc comment), then postFrameCallbacks (cleanup, and the only phase, proven above, where a widget can safely inspect its own just-finished layout). setState doesn’t run any of this directly — it just raises a flag (Element._dirty) and, the first time in a batch, taps ensureVisualUpdate() to make sure a relay is actually scheduled; every subsequent setState on an already-dirty element (proven above) is a no-op at the scheduling level, because the flag is already raised.
Trade-offs
Section titled “Trade-offs”| Mechanism | Benefit | Cost |
|---|---|---|
markNeedsBuild’s dirty-flag coalescing | Calling setState redundantly (proven above) costs nothing extra at the frame level | Relying on it to “just work” instead of avoiding redundant setState calls still pays the closure-allocation cost setState’s own doc comment warns about |
addPostFrameCallback | The only safe place to read this frame’s real layout results (proven above) | Runs strictly after the frame — reading and immediately acting on a size here means the action itself happens a frame later, not within the current one |
Ticker/transientCallbacks running before persistentCallbacks | Animation values are never “half-updated” when your build() reads them | A build() that reads animation.value must itself be cheap — it is squarely inside the persistentCallbacks critical path proven above |
One synchronous drawFrame per frame, no background thread for it | Deterministic, easy-to-reason-about ordering (proven above, step by step) | A single slow build()/performLayout()/paint() call blocks the ENTIRE frame — there is no separate thread absorbing it, which is exactly why offloading real CPU work needs Isolates & compute, not this pipeline |
Common Misconceptions
Section titled “Common Misconceptions”- “Calling
setStatetwice always triggers two separate rebuilds.” Proven false above: two synchronoussetStatecalls produced exactly one rebuild (buildCountwent from 1 to 2, not 3) —Element.markNeedsBuild’s ownif (dirty) return;guard, quoted above, is why. - “You can read a widget’s just-built size inside its own
build()method.” Proven false above:sizeDuringBuildwasnull— the render object for that frame doesn’t exist yet whilebuild()(step 1 ofdrawFrame) is running; onlyaddPostFrameCallback(step 8, genuinely after layout) saw the real123.0. - “
addPostFrameCallbackjust means ‘later’, roughly the same as a zero-durationFuture.delayed.” It is a specific, named phase (SchedulerPhase.postFrameCallbacks, quoted above) that the framework guarantees runs after layout/paint/semantics have completed for that frame — not merely “sometime after this microtask.” - “Animations sometimes read a half-updated value if a widget rebuilds mid-tick.” Proven false by the enum ordering above:
transientCallbacks(where tickers update) is a strictly earlier phase thanpersistentCallbacks(where widgets rebuild) — by construction, a widget’sbuild()never runs while a ticker for the same frame is still updating.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter code that needs a widget’s size or position after layout frequently reaches for Future.delayed(Duration.zero, ...) instead of addPostFrameCallback — it “usually works” in casual testing (a zero-duration Future often does resolve after the current frame in practice), but it is not the guarantee this lesson proved: a Future.delayed continuation runs as a normal event-queue task, with no documented relationship to SchedulerPhase at all.
// @expect-error compiles clean -- "usually" reads a real post-layout size in casual testing, but has no documented guarantee about running after layout specifically, unlike addPostFrameCallback (proven above to be a named, ordered SchedulerPhase)class MeasureAfterBuild extends StatefulWidget { const MeasureAfterBuild({super.key}); @override State<MeasureAfterBuild> createState() => _MeasureAfterBuildState();}
class _MeasureAfterBuildState extends State<MeasureAfterBuild> { final key = GlobalKey();
@override void initState() { super.initState(); Future.delayed(Duration.zero, () { final size = (key.currentContext!.findRenderObject() as RenderBox).size; debugPrint('measured: $size'); }); }
@override Widget build(BuildContext context) => SizedBox(key: key, width: 100, height: 100);}Before trusting generated code that needs a post-layout measurement, check:
- Does it use
SchedulerBinding.instance.addPostFrameCallback(orWidgetsBinding.instance.addPostFrameCallback, the same call) rather thanFuture.delayed/Timer/Future.microtask? Only the former is proven above to run in the specific, documentedpostFrameCallbacksphase, strictly after layout. - If it needs to measure on EVERY frame (not just once after a state change), is it using a
LayoutBuilder/RenderObject-level hook instead of re-registering a post-frame callback every frame, which adds a full frame of lag each time (proven above: the callback fires after the frame it was registered in, not within it)?
🐛 Spot the bug
class AutoScrollToBottom extends StatefulWidget {const AutoScrollToBottom({super.key, required this.controller, required this.child});final ScrollController controller;final Widget child;
@overrideState<AutoScrollToBottom> createState() => _AutoScrollToBottomState();}
class _AutoScrollToBottomState extends State<AutoScrollToBottom> {@overrideWidget build(BuildContext context) { if (widget.controller.hasClients) { widget.controller.jumpTo(widget.controller.position.maxScrollExtent); } return widget.child;}}Show the answer
Calling widget.controller.jumpTo(...) directly inside build() reads position.maxScrollExtent from a ScrollPosition that — per this lesson’s own proof above — has not been laid out for THIS frame yet; build() runs in step 1 of drawFrame, before layout (step 2) has computed this frame’s real content extent. In practice this either throws (before any layout has ever happened) or scrolls to last frame’s stale maxScrollExtent, one frame behind new content. The fix is exactly the mechanism proven above: move the jumpTo call into WidgetsBinding.instance.addPostFrameCallback((_) { ... }), called from build() (or better, only when new content actually arrived), so it runs in the genuinely-later postFrameCallbacks phase, after this frame’s layout has produced the real maxScrollExtent.