Rendering pipeline
Why Flutter keeps three parallel trees, what happens between setState and a
pixel changing, and which of that you can influence.
Contents
- Three trees
- How the trees are built
- Widget identity and keys
- The frame
- Build phase
- Layout phase
- Paint and composite
- Impeller
- What this means in practice
Three trees
| Tree | Lifetime | Job |
|---|---|---|
| Widget | Rebuilt constantly, immutable | Configuration — a description of what the UI should be |
| Element | Long-lived, mutable | The instance: holds State, position in the tree, and the link between a widget and its render object |
| RenderObject | Long-lived, mutable | Layout, painting, hit testing — the expensive part |
The split exists so that describing the UI can be cheap. Creating a widget is allocating a small immutable object; if widgets were the render tree, every rebuild would mean re-creating layout state. Instead the framework compares the new widget to the old one and mutates the existing render object only where something actually changed.
Widget Element RenderObject
─────────────────────────────────────────────────────
Padding ──► SingleChildElement ──► RenderPadding
Row ──► MultiChildElement ──► RenderFlex
Text ──► LeafElement ──► RenderParagraph
Not every widget has a render object. StatelessWidget and StatefulWidget
only produce other widgets — their elements are ComponentElement, and the
render tree is therefore much shallower than the widget tree.
How the trees are built
runAppinflates the root widget into an element.- Each element asks its widget to
createElement, then builds children. RenderObjectWidgets create a render object viacreateRenderObjectand attach it to the render tree.- On rebuild, the element receives a new widget and calls
updateRenderObjectrather than creating a new one.
The decision to reuse is Widget.canUpdate(old, new), which is true when
runtimeType and key both match. If it is true, the element is updated in
place and any State survives. If it is false, the old element is deactivated
and a new subtree is inflated — losing state, scroll position, and animation
progress.
Widget identity and keys
Keys control the matching when children are reordered, inserted, or removed.
| Key | Use |
|---|---|
ValueKey | Identity from a value: ValueKey(product.id) |
ObjectKey | Identity from an object instance |
UniqueKey | Force a new element every build — deliberately destroys state |
GlobalKey | Access state or context from outside; allows moving a subtree across the tree |
Rule of thumb: add keys to children of a list or Stack whose order can change
and whose items are stateful. Without them the framework matches by position and
state stays behind while data moves.
GlobalKey is expensive and easy to misuse — it makes an element globally
reachable and its subtree relocatable. Use it for the few cases that need it
(FormState, imperative access to a ScaffoldState) and not as a way to avoid
passing data down.
The frame
When the platform signals vsync, the engine runs:
vsync
├─ transient callbacks (tickers → animation values change)
├─ build (dirty elements rebuild)
├─ layout (dirty render objects re-measure)
├─ paint (record paint commands into layers)
├─ compositing (assemble the layer tree)
├─ semantics (accessibility tree, if enabled)
└─ post-frame callbacks
↓
raster thread: turn the layer tree into GPU commands and draw
Two threads matter. The UI thread runs Dart: build, layout, paint recording. The raster thread turns the recorded layer tree into GPU work. A frame is late if either exceeds the budget — 16.6 ms at 60 Hz, 8.3 ms at 120 Hz. DevTools shows both separately, which tells you whether to fix your Dart code or your painting.
Build phase
setState calls markNeedsBuild, which adds the element to the BuildOwner's
dirty list. Nothing rebuilds immediately — the framework rebuilds all dirty
elements in the next frame, in depth order (parents before children), so an
element is never built twice in one frame.
Consequences worth internalising:
setStateis a scheduling request, not a synchronous redraw.- Rebuilding a subtree does not mean repainting it. If the resulting widgets are identical, layout and paint are skipped.
- A
constwidget short-circuits the whole comparison: the same instance means nothing can have changed.
Layout phase
Constraints go down, sizes come up, the parent sets position — the model
described in Widgets and layout. Layout is a single
pass, which is why Flutter layout is O(n).
A relayout boundary stops layout propagating upward. The framework creates
one automatically when a render object's constraints are tight, it is not
allowed to size to its child, and the parent does not depend on its size. Inside
such a boundary, markNeedsLayout re-lays out only that subtree.
Intrinsic sizing (IntrinsicHeight, IntrinsicWidth, and some table layouts)
breaks the single-pass property: it asks children to measure themselves before
the real layout, so it can cost O(n²). Use it rarely and never inside a list
item.
Paint and composite
Painting walks the render tree recording commands into a layer tree. A repaint boundary isolates a subtree into its own layer so that repainting it does not repaint its neighbours.
RepaintBoundary(child: AnimatedChart(data: data))
Add one around content that repaints on a different cadence than its
surroundings — an animation, a video, a custom painter. Each boundary costs a
layer and some memory, so do not wrap everything; debugRepaintRainbowEnabled
shows what is actually repainting.
Operations that force saveLayer are the expensive ones because they allocate
an offscreen buffer:
Opacitywith a value strictly between 0 and 1ClipRRectand other anti-aliased clips withClip.antiAliasWithSaveLayer- Blend modes,
ShaderMask,BackdropFilter
Cheaper alternatives: FadeTransition or an opacity baked into a colour instead
of Opacity; a BorderRadius on a DecoratedBox instead of clipping; a
pre-composited image instead of a live backdrop blur.
Impeller
Impeller is the rendering backend used on mobile in current releases. It precompiles its shaders at build time, which removes the first-run shader compilation jank that Skia suffered from — the classic "the first swipe of every animation stutters" problem.
For these notes it matters in two places: it is a prerequisite for the Flutter GPU path in 3D with Flutter GPU and flutter_scene, and it changes how you interpret raster-thread timings. Confirm the backend and platform defaults for the exact Flutter version you ship against, since this area has moved quickly.
What this means in practice
| Goal | Action |
|---|---|
| Fewer rebuilds | const constructors, extract widgets into classes, scope listeners narrowly |
| Cheaper rebuilds | Pass a prebuilt child into AnimatedBuilder/Consumer builders |
| Fewer repaints | RepaintBoundary around independently animating subtrees |
| Cheaper paint | Avoid saveLayer triggers; prefer transform and opacity animations |
| Cheaper layout | Avoid intrinsics; give lists fixed extents with itemExtent or prototypeItem |
| Preserved state | Correct keys on reorderable stateful children |
| Diagnose | Widget rebuild profiler, repaint rainbow, and the frame chart in DevTools and performance |
Reading the framework source is the fastest way to make this concrete:
widgets/framework.dart for Widget, Element, and BuildOwner;
rendering/object.dart for RenderObject and PipelineOwner;
rendering/box.dart for BoxConstraints.
Next: Custom painting.