How does the Flutter rendering pipeline work?
4 min read
Flutter Internals
Theory — the 4 phases
| Phase | What runs | Key rule |
|---|---|---|
| Build | Widget.build() → produces / diffs the Widget & Element trees | Pure function — no side effects |
| Layout | RenderObject.performLayout() — parent passes constraints down, child returns size up | Single-pass, top-down constraints / bottom-up sizes |
| Paint | RenderObject.paint() — records draw commands into the Layer tree | No layout work allowed here |
| Composite | Layer tree → Scene → GPU rasterises pixels | Runs on the raster thread |
Frame budget: 16.67ms @ 60Hz, 8.33ms @ 120Hz. Anything longer = dropped frame.
Layout: constraints down, sizes up
Container(
width: 200,
height: 100,
child: Center( // forwards loose constraints
child: Text('Hello'), // sizes to content, returns size
),
)
Mental model — every layout call follows three steps:
- Parent gives child constraints (min/max width & height).
- Child picks a size within those constraints.
- Parent positions the child.
The Flutter mantra: "Constraints go down. Sizes go up. Parent sets position."
Build → Layout → Paint in code
class _State extends State<MyWidget> {
int _count = 0;
void _inc() => setState(() => _count++); // marks element dirty
@override
Widget build(BuildContext context) { // BUILD phase
return RepaintBoundary( // creates a paint boundary
child: Container( // LAYOUT decides size
padding: const EdgeInsets.all(16),
child: Text('$_count'), // PAINT draws text
),
);
}
}
What happens when _inc() runs:
- Element marked dirty
- Next frame →
build()runs (Build) - Layout pass updates the new subtree (Layout)
- Only this RepaintBoundary's layer repaints (Paint)
- GPU composites (Composite)
Performance levers
| Lever | What it does | When to use |
|---|---|---|
const constructors | Canonicalised widget — Flutter skips rebuilding subtree | Anywhere a widget config is constant |
RepaintBoundary | Isolates a subtree into its own layer | Animations, charts, anything that repaints often |
| Push state down | Smaller dirty subtree → less rebuild work | Lift to the narrowest widget that needs the state |
ListView.builder | Only builds visible items | Long / infinite lists |
| Cache expensive computations in state | Avoid recomputing in build() | Sorted lists, parsed data |
MediaQuery.sizeOf (vs .of) | Subscribe only to size, not all metrics | Avoid unrelated rebuilds |
Common mistakes to avoid
// ❌ Heavy work in build()
@override
Widget build(BuildContext context) {
final sorted = items.toList()..sort(); // recomputed every rebuild
}
// ✅ Cache in didChangeDependencies/didUpdateWidget or compute once in state
// ❌ Skipping const constructors
return Padding(padding: EdgeInsets.all(8), child: ...);
// ✅ const Padding(padding: EdgeInsets.all(8), child: ...)
// ❌ Rebuilding the whole screen on a tiny state change
class _BigScreen extends StatefulWidget { ... } // 1 setState rebuilds ALL of it
// ✅ Push the StatefulWidget down to the smallest possible subtree
// ❌ Wrapping every widget in RepaintBoundary
// They have a cost (extra layer). Use only around things that repaint independently.
// ❌ Assuming widgets are slow
// 99% of jank in real apps is in build() or layout(), not Flutter itself.
// Profile with the DevTools Performance tab before optimising.
Interview follow-ups
-
Why are widgets immutable if they're rebuilt every frame? Doesn't that waste memory? Building widgets is cheap — they're just config objects. The expensive trees (Element, RenderObject) persist and are diffed. Immutability makes the diff predictable and unlocks
constcanonicalisation. -
What's a
RepaintBoundaryand when is it worth one? A boundary that gives the subtree its own compositing layer. A repaint inside that subtree doesn't dirty the parent. Worth it for animations, charts, complex CustomPaint — anywhere repaints happen at a different rate than the surroundings. -
How does Flutter achieve smooth scrolling on huge lists?
ListView.builder(and the Sliver family) lazily builds items only inside the viewport plus a cache extent. Items leaving the cache are unbuilt; new items are built on demand. -
What runs on the UI thread vs the raster thread? Build, Layout, Paint run on the UI thread. Compositing/rasterisation runs on the raster thread. CPU-heavy work belongs on an isolate (
compute/Isolate.run) so neither thread stalls.
How helpful was this content?
Please sign in to rate this article.