The Render Pipeline
Five phases per frame
Section titled “Five phases per frame”When Flutter draws a frame, it runs a fixed pipeline. Knowing the phases tells you where your time goes and where to optimize.
flowchart LR build["1. Build (run build methods)"] --> layout["2. Layout (constraints down, sizes up)"] layout --> paint["3. Paint (record draw commands)"] paint --> composite["4. Composite (assemble layers)"] composite --> raster["5. Raster (GPU draws pixels)"]
- Build — dirty widgets rebuild, producing an updated widget description.
- Layout — the constraints-go-down/sizes-go-up pass (proven in depth in the Constraints & Layout lesson) computes every render object’s size and position.
- Paint — each render object records drawing commands (not pixels yet) into a
Canvas/Picture. Which render objects get their own layer here — proven directly below withRepaintBoundary— is what makes some repaints cheap and others expensive. - Composite — the layers are assembled into a final scene graph.
- Raster — the GPU actually draws pixels from that scene. This is where Impeller (below) changed what happens.
The budget is tight: to hit 60fps you have ~16.6ms per frame (~8.3ms at 120Hz). Jank is a frame that misses that budget, and it almost always comes from too much work in build, layout, or an unexpectedly expensive paint/composite step (proven below to be a real, measurable cost, not a vague concern).
RenderObjects do the real work
Section titled “RenderObjects do the real work”Widgets are lightweight, immutable descriptions. The objects that actually lay out and paint are RenderObjects, and they live in the third of Flutter’s three trees:
- Widget tree — your immutable configuration (rebuilt often, cheap).
- Element tree — the mutable glue that holds state and links widgets to render objects (proven in depth, with a spied
createElement/didUpdateWidget, in the Widgets and the Tree lesson). - Render tree — the
RenderObjects that compute layout and paint (expensive; kept and mutated, not rebuilt).
For the full mechanism behind this — markNeedsLayout, markNeedsPaint, and how a dirty render object gets revisited — see Flutter Internals: Layout and Paint.
Impeller: what it actually replaced at the raster step
Section titled “Impeller: what it actually replaced at the raster step”Impeller precompiles all shaders and builds all pipeline state objects (PSOs) upfront, at engine-build time — this is a direct quote from docs.flutter.dev/perf/impeller’s own description of Impeller’s objective: “Predictable performance: Impeller compiles all shaders and reflection offline at build time. It builds all pipeline state objects upfront. The engine controls caching and caches explicitly.” This is the specific thing it replaces: Skia (the previous default renderer) instead compiled shaders at runtime, the first time a given draw operation was encountered — which is exactly what caused the classic “first run of a new animation stutters, then is smooth” jank, since that first-time shader compilation happened live, on the frame that needed it.
Real per-platform status, as of this course’s Flutter 3.47 SDK:
- iOS: per
docs.flutter.dev/perf/impeller, “Impeller is the only supported rendering engine on iOS with no ability to switch to Skia.” - Android: Impeller became the default on modern (Vulkan-capable) devices starting Flutter 3.27 — quoting the official release notes: “Impeller is now the default rendering engine on modern Android devices… On older Android devices and devices without Vulkan support, the Skia rendering engine will still be used as before.” Opt out with
flutter run --no-enable-impeller(or the equivalentAndroidManifest.xmlmeta-data entry). - macOS, Windows, Linux: Flutter 3.47 made Impeller the default here too — this course’s own Build and Release lesson states it plainly (verified against
docs.flutter.devduring this course’s Phase 3 review): “It became the default on iOS and Android earlier; Flutter 3.47 makes it the default on macOS, Windows, and Linux too.”
--enable-impeller/--no-enable-impeller are the real CLI flags (the exact ones quoted in Flutter’s own release notes for opting in during the preview period and opting out today on Android) — but note that on iOS there is nothing to opt out to, since Skia isn’t shipped there at all anymore. For the full Impeller architecture (Aiks, Entities, the actual PSO-precompilation pipeline), see Flutter Internals: Impeller and the GPU.
Proven: RepaintBoundary genuinely isolates one subtree’s repaints from its siblings
Section titled “Proven: RepaintBoundary genuinely isolates one subtree’s repaints from its siblings”By default, when a non-boundary render object needs to repaint, Flutter must re-record the whole shared Picture up to the nearest ancestor repaint boundary — which means every sibling under that same boundary gets its paint() called again too, even ones that didn’t change at all. Wrapping the frequently-changing subtree in its own RepaintBoundary gives it a separate layer, so its repaints no longer force the rest of that shared canvas to be re-recorded.
import 'package:flutter/material.dart';
int staticPaintCount = 0;int spinnerPaintCount = 0;
class _CountingPainter extends CustomPainter { _CountingPainter(this.onPaint, this.color); final VoidCallback onPaint; final Color color; @override void paint(Canvas canvas, Size size) { onPaint(); canvas.drawRect(Offset.zero & size, Paint()..color = color); }
@override bool shouldRepaint(_CountingPainter oldDelegate) => color != oldDelegate.color;}
/// Never changes on its own — a good proxy for "unrelated sibling content".class StaticBox extends StatelessWidget { const StaticBox({super.key}); @override Widget build(BuildContext context) { return CustomPaint(size: const Size(40, 40), painter: _CountingPainter(() => staticPaintCount++, Colors.blue)); }}
/// Repaints every time tick changes — the "animating" widget.class SpinnerBox extends StatefulWidget { const SpinnerBox({super.key}); @override SpinnerBoxState createState() => SpinnerBoxState();}
class SpinnerBoxState extends State<SpinnerBox> { int tick = 0; void bump() => setState(() => tick++); @override Widget build(BuildContext context) { return CustomPaint( size: const Size(40, 40), painter: _CountingPainter(() => spinnerPaintCount++, Color.fromARGB(255, tick % 255, 0, 0)), ); }}
/// No RepaintBoundary around the spinner — both siblings share one canvas.class NoBoundaryScene extends StatelessWidget { const NoBoundaryScene({super.key, required this.spinnerKey}); final GlobalKey<SpinnerBoxState> spinnerKey; @override Widget build(BuildContext context) { return Column(children: [SpinnerBox(key: spinnerKey), const StaticBox()]); }}
/// The spinner gets its OWN layer — its repaints stop leaking into StaticBox.class WithBoundaryScene extends StatelessWidget { const WithBoundaryScene({super.key, required this.spinnerKey}); final GlobalKey<SpinnerBoxState> spinnerKey; @override Widget build(BuildContext context) { return Column(children: [ RepaintBoundary(child: SpinnerBox(key: spinnerKey)), const StaticBox(), ]); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/repaint_boundary_demo.dart';
void main() { testWidgets('WITHOUT a boundary, an unrelated sibling repaints too', (tester) async { staticPaintCount = 0; spinnerPaintCount = 0; final key = GlobalKey<SpinnerBoxState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: NoBoundaryScene(spinnerKey: key)))); expect(staticPaintCount, 1);
key.currentState!.bump(); await tester.pump();
expect(spinnerPaintCount, 2, reason: 'the spinner itself changed, so of course it repaints'); expect(staticPaintCount, 2, reason: 'StaticBox never changed, yet it was repainted too — same shared canvas'); });
testWidgets('WITH a RepaintBoundary, the sibling stops repainting', (tester) async { staticPaintCount = 0; spinnerPaintCount = 0; final key = GlobalKey<SpinnerBoxState>(); await tester.pumpWidget(MaterialApp(home: Scaffold(body: WithBoundaryScene(spinnerKey: key)))); expect(staticPaintCount, 1);
key.currentState!.bump(); await tester.pump();
expect(spinnerPaintCount, 2, reason: 'the spinner still repaints — it genuinely changed'); expect(staticPaintCount, 1, reason: 'StaticBox is now on its own layer boundary — the spinner\'s repaint no longer touches it'); });}00:00 +0: WITHOUT a boundary, an unrelated sibling repaints too00:00 +1: WITH a RepaintBoundary, the sibling stops repainting00:00 +2: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__render-pipeline/ run on this Mac)
staticPaintCount climbing to 2 in the first test — for a widget whose own painter never changed at all — is the concrete cost RepaintBoundary exists to eliminate; the second test proves the fix genuinely works, not just in theory.
Proven: real debugDumpRenderTree/debugDumpLayerTree output
Section titled “Proven: real debugDumpRenderTree/debugDumpLayerTree output”Both are real diagnostic dumps of the actual live trees — not a description of them. Trimmed excerpts from a live flutter test run of MaterialApp(home: Scaffold(body: Center(child: Text('Hi')))):
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';
void main() { testWidgets('dumps the real render and layer trees', (tester) async { await tester.pumpWidget(const MaterialApp(home: Scaffold(body: Center(child: Text('Hi'))))); debugDumpRenderTree(); debugDumpLayerTree(); });}00:00 +0: dumps the real render and layer trees00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__render-pipeline/ run on this Mac — a real dump is hundreds of lines for a full MaterialApp; object hashes like #b98ff are per-run memory addresses, not stable identifiers)
debugDumpRenderTree(), real excerpt (the tree’s root, then the leaf Text’s RenderParagraph, elided in between):
_ReusableRenderView#26ce3 │ debug mode enabled - macos │ view size: Size(2400.0, 1800.0) (in physical pixels) │ device pixel ratio: 3.0 (physical pixels per logical pixel) │ configuration: BoxConstraints(w=800.0, h=600.0) at 3.0x (in │ logical pixels) … └─child: RenderParagraph#b98ff relayoutBoundary=up2 │ creator: RichText ← Text ← Center ← KeyedSubtree-[GlobalKey#7a44e] ← │ _BodyBuilder ← MediaQuery ← LayoutId-[<_ScaffoldSlot.body>] ← ⋯ │ parentData: offset=Offset(385.8, 290.0) (can use size) │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0) │ semantics node: SemanticsNode#4 │ size: Size(28.5, 20.0)debugDumpLayerTree(), real excerpt (the root transform layer, down to the actual PictureLayer that holds the recorded drawing commands):
TransformLayer#98ffa │ owner: _ReusableRenderView#6a1f8 │ engine layer: TransformEngineLayer#4685e │ transform: │ [0] 3.0,0.0,0.0,0.0 │ [1] 0.0,3.0,0.0,0.0 │ [2] 0.0,0.0,1.0,0.0 │ [3] 0.0,0.0,0.0,1.0 … │ └─child 1: OpacityLayer#7b400 │ │ creator: FadeTransition ← IgnorePointer ← FractionalTranslation ← │ │ SlideTransition ← FadeTransition ← DualTransitionBuilder ← ⋯ │ │ alpha: 255 │ … │ ├─child 1: PictureLayer#2517a │ │ paint bounds: Rect.fromLTRB(0.0, 0.0, 800.0, 600.0) │ │ picture: _NativePicture#15e96Two things worth noticing directly in that real output: the root TransformLayer’s matrix is a 3.0 scale — the test device’s pixel ratio, applied once at the very top, not per-widget; and there’s already an OpacityLayer in a completely plain Center(child: Text('Hi')) app, from the route transition machinery (FadeTransition), before your own code ever touches Opacity — exactly the mechanism proven next.
Proven: Opacity allocates a real compositing layer even at opacity: 1.0 — only 0.0 is genuinely free
Section titled “Proven: Opacity allocates a real compositing layer even at opacity: 1.0 — only 0.0 is genuinely free”The Opacity widget’s own doc comment says “Values one and zero are painted with a fast path. Other values require painting the child into an intermediate buffer, which is expensive.” Reading RenderOpacity’s actual source (~/flutter/packages/flutter/lib/src/rendering/proxy_box.dart) shows the real, more precise mechanism: alwaysNeedsCompositing => child != null && _alpha > 0 — true for any non-zero alpha, including 255 (opacity 1.0). Only opacity: 0.0 (_alpha == 0) skips compositing and painting entirely. Provable directly by inspecting the actual layer tree at each value:
import 'package:flutter/material.dart';
class OpacityDemo extends StatelessWidget { const OpacityDemo({super.key, required this.opacity}); final double opacity; @override Widget build(BuildContext context) { return RepaintBoundary( child: Opacity( opacity: opacity, child: Container(width: 40, height: 40, color: Colors.blue), ), ); }}import 'package:flutter/material.dart';import 'package:flutter/rendering.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/opacity_layer_demo.dart';
void main() { testWidgets('opacity: 1.0 still allocates its own OpacityLayer (alpha 255); only 0.0 omits it', (tester) async { await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 1.0)))); final layersAtFull = tester.layers.whereType<OpacityLayer>().toList(); final ownLayerAtFull = layersAtFull.where((l) => l.alpha == 255).length;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 0.5)))); final layersAtHalf = tester.layers.whereType<OpacityLayer>().where((l) => l.alpha == 128).length;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: OpacityDemo(opacity: 0.0)))); final totalLayersAtZero = tester.layers.whereType<OpacityLayer>().length;
expect(ownLayerAtFull, greaterThan(0), reason: 'opacity: 1.0 (alpha 255) DOES get its own OpacityLayer, contrary to "fully opaque = free"'); expect(layersAtHalf, 1, reason: 'opacity: 0.5 (alpha 128) gets the same kind of layer, just a different alpha value'); expect(totalLayersAtZero, lessThan(layersAtFull.length), reason: 'opacity: 0.0 is the one value that genuinely omits this widget\'s own layer'); });}00:00 +0: opacity: 1.0 still allocates its own OpacityLayer (alpha 255); only 0.0 omits it00:00 +1: All tests passed!(trimmed from the real flutter test test/lessons/layout-and-rendering__render-pipeline/ run on this Mac)
On this run, the concrete counts were: opacity: 1.0 → an OpacityLayer with alpha: 255 genuinely present; opacity: 0.5 → an OpacityLayer with alpha: 128; opacity: 0.0 → that widget’s own layer is gone entirely (only the ambient route-transition layers from the screenshot above remain). The commonly-repeated “opacity 1.0 costs nothing, same as no Opacity widget at all” is not quite what this SDK does — it still creates a real compositing boundary and layer object at full opacity; the actual GPU cost difference at 1.0 vs a fractional value is in how cheaply that layer composites (an alpha-255 layer is a trivial passthrough for the rasterizer), not in whether a layer exists at all.
AnimatedOpacity/FadeTransition: same compositing mechanism, not an exemption from its cost
Section titled “AnimatedOpacity/FadeTransition: same compositing mechanism, not an exemption from its cost”AnimatedOpacity’s underlying RenderAnimatedOpacityMixin (~/flutter/packages/flutter/lib/src/rendering/proxy_box.dart) computes isRepaintBoundary the same way RenderOpacity does — it goes through the identical layer-allocation mechanism proven above, just with the alpha value changing every animation tick instead of once. The value AnimatedOpacity/FadeTransition add over a hand-rolled setState + Opacity(opacity: value) loop is scheduling, not a discount on the compositing cost proven above: the framework drives the value via a ticker-based implicit animation (no manual setState boilerplate, correct interpolation, automatic disposal), but every frame during the fade still pays for updating that same kind of OpacityLayer.
ClipRRect and the real cost of Clip.antiAliasWithSaveLayer
Section titled “ClipRRect and the real cost of Clip.antiAliasWithSaveLayer”Clip — the enum every clipping widget (ClipRRect, ClipRect, ClipPath, ClipOval, Container’s clipBehavior, …) takes — has its performance trade-offs documented directly on each value, quoted verbatim from dart:ui’s own source (mirrored at api.flutter.dev/flutter/dart-ui/Clip.html):
Clip.none: “This is the default option for most widgets: if the content does not overflow the widget boundary, don’t pay any performance cost for clipping.”Clip.hardEdge: “Faster than other clipping modes, but slower than none” — clips without anti-aliasing, so curves/diagonals are jagged.Clip.antiAlias(ClipRRect’s own default): “Much faster than antiAliasWithSaveLayer, but slower than hardEdge… this clipping can have bleeding edge artifacts.”Clip.antiAliasWithSaveLayer: “This mode not only clips with anti-aliasing, but also allocates an offscreen buffer. All subsequent paints are carried out on that buffer before finally being clipped and composited back. This is very slow.”
That last one is the real, documented cost this section is about: Clip.antiAliasWithSaveLayer is the one option that genuinely forces a Canvas.saveLayer-based offscreen buffer — the exact same class of cost proven above for fractional Opacity — specifically to avoid the bleeding-edge artifact Clip.antiAlias can otherwise have. Reach for it only when that specific artifact is visible and unacceptable; Clip.antiAlias (the default) is correct for the overwhelming majority of rounded-corner clipping.
Frame budget
Section titled “Frame budget”To hit 60fps you have ~16.6ms per frame; at 120Hz (many modern phones) that halves to ~8.3ms. Every technique proven in this lesson — a RepaintBoundary around what actually changes, choosing Clip.antiAlias over Clip.antiAliasWithSaveLayer unless truly needed, knowing that Opacity always costs a layer once non-zero — exists to keep the build/layout/paint/composite/raster pipeline inside that window. Missing it once is invisible; missing it repeatedly is the jank a user actually notices. For the scheduler mechanics that turn “something changed” into “a frame runs,” see Flutter Internals: Layout and Paint and, for what changed at the raster step specifically, Flutter Internals: Impeller and the GPU.
Mental Model
Section titled “Mental Model”The five phases are a print shop’s assembly line: build is the editor deciding what the page should say; layout is the typesetter figuring out exactly where every word and image goes; paint is inking the printing plates — one plate per section that doesn’t need to change with the others (a RepaintBoundary is a section getting its own plate, so re-inking it never disturbs the plates around it, proven above); composite is stacking all the inked plates in the right order; and raster is the actual press running the paper through and depositing ink — the step Impeller sped up by having the plates’ exact ink formulas precomputed ahead of time (PSOs), instead of mixing a new ink recipe live, mid-press-run, the first time a color was needed (Skia’s runtime shader compilation).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
RepaintBoundary around a frequently-repainting subtree | Isolates its repaints from siblings — proven above | Each boundary is its own layer/memory allocation; overusing it everywhere adds overhead for no benefit |
Opacity at a fractional value | Simple, declarative fading | A real compositing layer every frame it’s non-zero, proven above — not free even at first glance |
Clip.antiAlias (default) | Fast, smooth edges, correct for almost all cases | Can show bleeding-edge artifacts in rare cases |
Clip.antiAliasWithSaveLayer | Eliminates that bleeding artifact completely | A genuine offscreen buffer per the engine’s own docs — “very slow,” reach for it deliberately, not by default |
| Impeller (the platform default) | Removes first-run shader-compile jank — proven mechanism above | None you control directly — it is the default; --no-enable-impeller exists only where a Skia fallback still ships (Android) |
Common Misconceptions
Section titled “Common Misconceptions”- “An unrelated sibling widget never repaints just because something else changed.” Proven false above: without a
RepaintBoundary,staticPaintCountclimbed from1to2purely because a sibling’sCustomPainterchanged, sharing the same canvas. - “Opacity at 1.0 is completely free, identical to not using Opacity at all.” Proven false above by inspecting the actual layer tree: an
OpacityLayerwithalpha: 255is genuinely allocated. Onlyopacity: 0.0is the true fast path. - “AnimatedOpacity is cheaper than manually animating Opacity.” Proven above to share the identical underlying compositing mechanism — the real benefit is correct, ticker-driven scheduling, not a discount on the per-frame layer cost.
- “Clip.antiAlias and Clip.antiAliasWithSaveLayer are basically the same, just a naming difference.” The engine’s own doc comments, quoted directly above, are explicit: only the latter allocates a real offscreen buffer and is described as “very slow” — they are not interchangeable defaults.
- “Impeller only matters on iOS.” Proven above with the real per-platform status: it’s the exclusive renderer on iOS, the default on modern Android, and — as of 3.47 — the default on macOS, Windows, and Linux too.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Flutter code frequently reaches for Opacity to hide-and-show widgets (Opacity(opacity: visible ? 1.0 : 0.0, child: ...)) — functionally correct, but it keeps the hidden widget fully laid out, painted (at opacity: 1.0, proven above to genuinely cost a layer), and hit-testable, when Visibility or simply not building the widget at all would be both cheaper and more correct (a “hidden” Opacity: 0.0 widget still occupies its layout space and, without alwaysIncludeSemantics/IgnorePointer, may still be interactable in some configurations).
// @expect-error compiles clean — the "hidden" widget still lays out, paints its own compositing layer at opacity 1.0 while visible, and stays in the tree; Visibility or a conditional build would be both cheaper and clearerclass ToggleableCard extends StatelessWidget { const ToggleableCard({super.key, required this.visible}); final bool visible;
@override Widget build(BuildContext context) { return Opacity( opacity: visible ? 1.0 : 0.0, child: const ExpensiveCard(), ); }}Before trusting generated code that reaches for Opacity, check:
- Is it being used to actually fade between two values over time (a real use case), or to binary hide/show a widget (where
Visibility, anifinbuild(), orIgnorePointeris usually clearer and cheaper)? - Is a
ClipRRect/ClipPathset toClip.antiAliasWithSaveLayerwithout a specific bleeding-edge artifact it’s fixing? That’s the documented “very slow” option, not a safe default. - Does a frequently-animating widget (a spinner, a progress indicator, a live chart) sit as a direct sibling of static content with no
RepaintBoundarybetween them? Proven above, that static content pays a real, measurable repaint cost it shouldn’t.
🐛 Spot the bug
class LoadingOverlay extends StatelessWidget {const LoadingOverlay({super.key, required this.loading, required this.child});final bool loading;final Widget child;
@overrideWidget build(BuildContext context) { return Stack( children: [ child, Opacity( opacity: loading ? 1.0 : 0.0, child: const Center(child: CircularProgressIndicator()), ), ], );}}Show the answer
When loading is false, the CircularProgressIndicator is invisible but — proven above — an opacity: 0.0 Opacity widget is actually the ONE case that genuinely skips its own compositing layer AND its child’s paint call entirely, so this specific code is not paying the layer cost while hidden. The real, more subtle bug: the spinner is still fully laid out and still animating (its own internal AnimationController keeps ticking, scheduling frames) even while invisible, burning CPU for animation nobody sees. The fix: gate it with a conditional build (if (loading) Center(child: CircularProgressIndicator())) instead of Opacity, so the spinner — and its ticking animation — genuinely does not exist while not loading.