Flutter rendering pipeline explained
By Ann Tech · 21 January 2025
Understanding Flutter's rendering pipeline helps you diagnose jank, write efficient widgets, and explain why certain patterns perform better than others.
The three trees
Flutter maintains three parallel trees:
- Widget tree — your code (
build()methods). Immutable, rebuilt frequently. - Element tree — the "live" instances that persist between rebuilds. Manages lifecycle.
- RenderObject tree — the layout and painting layer. Expensive to create and modify.
When you call setState, Flutter rebuilds widgets but reuses elements and render objects where possible.
Build → Layout → Paint → Composite
Phase 1: Build
build() is called on widgets that are marked dirty. Flutter walks the widget tree top-down. Widgets return their child subtree.
// This build method runs on every setState anywhere in this subtree
@override
Widget build(BuildContext context) {
// Computationally cheap here — returns a description, not pixels
return ColoredBox(color: color, child: child);
}
Key insight: build() is cheap. Returning a widget description does no layout or painting. Don't avoid rebuilds at the cost of code clarity.
Phase 2: Layout
The render tree computes size and position. Flutter uses a single-pass constraint system:
- Parent sends constraints (min/max width/height) to child
- Child determines its size within those constraints
- Parent positions the child
Parent: "You have 200-300px width, 0-infinity height"
Child: "I'll be 250×100px"
Parent: "I'll place you at (0, 50)"
This is why unbounded constraints (ListView inside a Column without Expanded) cause exceptions — the child has no max height to constrain itself.
Phase 3: Paint
Render objects paint into a Canvas. Operations include drawing rectangles, text, images, and paths.
class _FancyBorder extends SingleChildRenderObjectWidget {
// ...
}
class _FancyBorderRender extends RenderProxyBox {
@override
void paint(PaintingContext context, Offset offset) {
super.paint(context, offset); // Paint child first
context.canvas.drawRRect(
RRect.fromRectAndRadius(offset & size, const Radius.circular(8)),
Paint()..color = Colors.blue..style = PaintingStyle.stroke..strokeWidth = 2,
);
}
}
Phase 4: Composite
The SceneBuilder assembles paint layers into a GPU scene. The compositor on the GPU renders the final frame.
Some widgets create their own layer (RepaintBoundary, Opacity < 1.0, transforms). Layers can be cached and composited without repainting.
RepaintBoundary
Wrap frequently-animating widgets in RepaintBoundary to isolate them from their parents:
// Without RepaintBoundary: animating the progress bar repaints the whole card
Card(
child: Column(children: [
OrderDetails(),
AnimatedProgressBar(progress: _progress),
]),
)
// With RepaintBoundary: only the progress bar layer repaints
Card(
child: Column(children: [
OrderDetails(),
RepaintBoundary(
child: AnimatedProgressBar(progress: _progress),
),
]),
)
const widgets
// Rebuilt on every parent rebuild — allocates a new widget object
child: Padding(padding: EdgeInsets.all(16), child: Text('Hello')),
// Cached — same instance reused, no rebuild
child: const Padding(padding: EdgeInsets.all(16), child: Text('Hello')),
const widgets are canonicalized — Flutter skips the diffing for them. Use const everywhere possible.
Keys
Keys tell Flutter how to match old and new widget instances:
// Without keys: items reorder but state doesn't follow
ListView(
children: items.map((item) => ItemWidget(item: item)).toList(),
)
// With keys: each ItemWidget's state follows its data across rebuilds
ListView(
children: items.map((item) => ItemWidget(key: ValueKey(item.id), item: item)).toList(),
)
Profile mode
flutter run --profile # Real performance, no dev tools overhead
In profile mode:
- Use DevTools "Performance" tab to record a timeline
- Look for frames exceeding 16ms (60fps) or 8ms (120fps)
- "UI thread" spike = Dart build/layout is slow
- "Raster thread" spike = painting/compositing is slow
Common rendering pitfalls
Opacity widget over large subtrees. Opacity(opacity: 0.5, child: LargeWidget()) creates a new compositing layer and repaints the whole subtree every frame. For static content, use Color.withOpacity() instead. For animated opacity, use AnimatedOpacity or FadeTransition.
Unbounded height in scroll views. ListView inside SingleChildScrollView inside another ListView causes layout thrashing. Use SliverList/CustomScrollView for nested scrollable layouts.
Blocking the UI thread. JSON parsing, image decoding, or any synchronous work over ~2ms on the main isolate causes jank. Move it to a background isolate with compute() or Isolate.run().
Sign in to like, dislike, or report.