How Flutter Works Internally
What this module is for
Section titled “What this module is for”Every earlier module in this course taught you to use Flutter: build widgets, manage state, navigate, lay out screens, ship a build. None of it required knowing what setState actually does underneath, what a “relayout boundary” is, why Impeller’s PSOs are “precompiled,” or what gen_snapshot actually produces. That gap is exactly where a confident-sounding explanation of Flutter (human- or AI-written) goes wrong in ways flutter analyze alone won’t catch — a plausible-sounding claim about “the UI and raster threads merging” or “each isolate having its own totally separate heap” reads as authoritative right up until you check the actual source or run the actual command.
This module answers five internals questions, each proven against the real, installed Flutter 3.47.5 / Dart 3.13.4 source (revision 6a19cca564) or a real command run on this Mac, never asserted from memory:
- What actually happens between a widget rebuilding and a pixel changing? (The Three Trees, From the Source —
Widget.canUpdate,Element.updateChild’s real four-case table, a customRenderObjectWidgetprovingcreateRenderObject/updateRenderObject/performLayout/paintare four separately-triggerable hooks, andGlobalKeyreparenting proven withidentical()) - What turns “a widget called
setState” into “a frame actually runs,” and in what order? (Build Scheduling and Frames — the realSchedulerPhaseenum,WidgetsBinding.drawFrame’s own 8-step doc comment, and a real test proving two synchronoussetStatecalls coalesce into exactly one build) - Where, precisely, does a chain of
markNeedsLayoutcalls stop climbing the tree? (Layout and Paint Internals — the exact relayout-boundary rule quoted from source, proven with a customRenderBoxpair and a realdebugDumpRenderTreeshowing the boundary annotation appear and disappear) - What does Impeller actually replace, and is “UI+raster merged” even true? (Impeller and the GPU — the engine’s own README and
Entity/Contents/RenderPasssource, and a real correction: the documented 3.29+ thread merge is UI+platform, not UI+raster) - What does
flutter buildactually run, and what does a Dart isolate’s memory model really look like? (The Dart VM and Isolates — real, verboseflutter build apk/flutter build weboutput, a real ELFlibapp.soextracted and inspected withfile(1), and a live, grepped proof that akDebugModebranch is deleted from a release build)
How every proof in this module was made
Section titled “How every proof in this module was made”Every source-level claim below cites a real permalink into github.com/flutter/flutter at this course’s pinned revision 6a19cca564 (Flutter 3.47.5), or github.com/dart-lang/sdk at the matching pinned tag 3.13.4 — the same revision flutter --version reports on this Mac, so every quoted line number matches what’s linked, even after this SDK is eventually upgraded. Every runtime claim is a real flutter test run against this course’s shared probe (real, trimmed output, never fabricated), and every build-pipeline claim (Impeller and the GPU’s CLI --help text, The Dart VM and Isolates’s flutter build apk/flutter build web -v output) is a real command run on this Mac, either through the probe or a throwaway scratch flutter create app for anything the shared probe can’t safely run (a full APK/web build). Where this environment has no device and a claim genuinely can’t be verified locally (DevTools raster-stats screenshots, a real production Impeller frame trace), each lesson says so directly instead of guessing.
flowchart TD t["The Three Trees, From the Source\n(Widget/Element/RenderObject)"] --> f["Build Scheduling and Frames\n(setState -> a real frame)"] t --> l["Layout and Paint Internals\n(the relayout-boundary rule)"] f --> l l --> i["Impeller and the GPU\n(what actually runs on the raster thread)"] f --> v["The Dart VM and Isolates\n(the real build pipeline)"] i --> v
Reading order and what each lesson assumes
Section titled “Reading order and what each lesson assumes”| Lesson | Builds on (Phase 3) | Answers |
|---|---|---|
| The Three Trees, From the Source | Widgets and the Tree | Why does same-type-same-key reuse an Element, and what does Widget.canUpdate actually compare? |
| Build Scheduling and Frames | The Render Pipeline, Async in Dart | What is the real chain from one setState() call to one scheduled frame, and why do redundant setState calls coalesce? |
| Layout and Paint Internals | Constraints & Layout, The Render Pipeline | Where, exactly, does a markNeedsLayout() call stop propagating up the tree? |
| Impeller and the GPU | The Render Pipeline | What is Impeller’s real architecture, and are the UI and raster threads actually merged? |
| The Dart VM and Isolates | Isolates & compute, Build & Release | What does flutter build actually invoke, and what does an isolate’s memory model really look like? |
Each Phase 3 lesson linked above already showed you the outside view (a widget test counting rebuilds, a real build’s reported size, a real error message). This module shows the mechanism behind that view — it does not re-teach the outside view, it links to it and goes one level deeper.
Mental Model
Section titled “Mental Model”Every lesson in this module is the same idea applied to a different layer: Flutter’s internals are not a black box — they are a fixed set of source files and real, runnable commands that will always show you the actual mechanism directly. “Rebuilding is expensive” is not a slogan; it’s Widget.canUpdate’s exact two-field test, provable with a spy. “A relayout boundary stops the climb” is not a metaphor; it’s one literal line, _isRelayoutBoundary = !parentUsesSize || sizedByParent || constraints.isTight || parent == null, provable with a counter and a real debugDumpRenderTree. “Impeller removed shader jank” is not marketing; it’s ContentContextOptions’ own doc comment bounding PSO variants at “hundreds, not 10,000s.” Once you know which source file or which command proves which claim, “trust me, that’s how Flutter works under the hood” stops being necessary — for your own claims, and for judging an AI’s.
Common Misconceptions
Section titled “Common Misconceptions”- “A widget rebuilding always means a new
RenderObject.” The Three Trees proves the opposite with a real counter:createRenderObjectruns once; same-key rebuilds only ever pay forupdateRenderObject. - “Calling
setStatetwice always triggers two rebuilds.” Build Scheduling and Frames provesElement.markNeedsBuild’s ownif (dirty) return;guard coalesces two synchronous calls into exactly one. - “Marking a widget as needing layout always relays out everything above it.” Layout and Paint Internals proves a relayout boundary genuinely absorbs the dirty flag — a real
debugDumpRenderTreeconfirms it from a second, independent angle. - “Impeller merged the UI thread and the raster thread.” Impeller and the GPU proves, with real
docs.flutter.devquotes, that the actual 3.29+ merge is UI+platform on iOS/Android — the raster thread, where Impeller/Skia actually talk to the GPU, was never merged with the UI thread. - “Each Dart isolate has its own completely separate heap.” The Dart VM and Isolates proves, from
dart-lang/sdk’s own glossary, that isolates in the same group share a heap — and then proves, fresh, thatcompute()’s message passing still copies rather than aliases regardless.
Reading AI-generated code
Section titled “Reading AI-generated code”A preview of the pattern every lesson in this module proves at least once: a plausible-sounding claim about Flutter internals that a real source citation or a real test disproves.
🐛 Spot the bug
// A comment an AI assistant added above a custom RenderBox// "sizedByParent means the framework never needs to re-check this// widget's layout when its internal state changes -- just call// markNeedsLayout() like any other render object."class _RenderFixedHeightBox extends RenderBox {@overridebool get sizedByParent => true;
double _height = 40;set targetHeight(double v) { _height = v; markNeedsLayout();}
@overrideSize computeDryLayout(BoxConstraints constraints) => constraints.constrain(Size(constraints.maxWidth, _height));}Show the answer
Proven wrong in Layout and Paint Internals, with a real, run test: a sizedByParent: true object is unconditionally its own relayout boundary (proven from the exact source rule that lesson quotes), which means plain markNeedsLayout() never notifies the parent when this object’s own size changes — the parent silently keeps a stale size. sizedByParent’s own doc comment requires markNeedsLayoutForSizedByParentChange() instead for exactly this case, and that lesson’s test proves the difference directly: with the plain call, the parent’s observed child width stayed stuck at the old value; with the correct call, it updated.