Skip to main content

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

TreeLifetimeJob
WidgetRebuilt constantly, immutableConfiguration — a description of what the UI should be
ElementLong-lived, mutableThe instance: holds State, position in the tree, and the link between a widget and its render object
RenderObjectLong-lived, mutableLayout, 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

  1. runApp inflates the root widget into an element.
  2. Each element asks its widget to createElement, then builds children.
  3. RenderObjectWidgets create a render object via createRenderObject and attach it to the render tree.
  4. On rebuild, the element receives a new widget and calls updateRenderObject rather 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.

KeyUse
ValueKeyIdentity from a value: ValueKey(product.id)
ObjectKeyIdentity from an object instance
UniqueKeyForce a new element every build — deliberately destroys state
GlobalKeyAccess 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:

  • setState is 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 const widget 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:

  • Opacity with a value strictly between 0 and 1
  • ClipRRect and other anti-aliased clips with Clip.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

GoalAction
Fewer rebuildsconst constructors, extract widgets into classes, scope listeners narrowly
Cheaper rebuildsPass a prebuilt child into AnimatedBuilder/Consumer builders
Fewer repaintsRepaintBoundary around independently animating subtrees
Cheaper paintAvoid saveLayer triggers; prefer transform and opacity animations
Cheaper layoutAvoid intrinsics; give lists fixed extents with itemExtent or prototypeItem
Preserved stateCorrect keys on reorderable stateful children
DiagnoseWidget 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.