Rebuilds & Performance
Rebuilds are cheap — until they aren’t
Section titled “Rebuilds are cheap — until they aren’t”Rebuilding a widget subtree is usually fast: Flutter diffs the new widgets against the elements and updates only what changed. The problem is doing it more than necessary — rebuilding a big subtree when one small piece changed, every frame of an animation. The levers below all reduce wasted rebuild and repaint work, each one proven with a real widget test, not a guess.
Measuring rebuilds for real: debugPrintRebuildDirtyWidgets and a build-counter methodology
Section titled “Measuring rebuilds for real: debugPrintRebuildDirtyWidgets and a build-counter methodology”Flutter ships a real debug flag — debugPrintRebuildDirtyWidgets (declared in package:flutter/src/widgets/debug.dart, re-exported through package:flutter/widgets.dart/package:flutter/material.dart, a top-level bool) — that makes every Element.rebuild() call print Building $this or Rebuilding $this via debugPrint. It’s genuinely useful for a one-off “what actually rebuilt when I tapped this button” investigation, but its own output (captured below) shows why it’s rarely the everyday tool: a single setState inside a MaterialApp triggers dozens of framework-internal rebuilds (focus scopes, the button’s ink response, its Material ripple) that have nothing to do with your own widgets.
import 'package:flutter/material.dart';
int constChildBuildCount = 0;int dynamicChildBuildCount = 0;
class ConstChild extends StatelessWidget { const ConstChild({super.key}); @override Widget build(BuildContext context) { constChildBuildCount++; return const Text('const child'); }}
class DynamicChild extends StatelessWidget { const DynamicChild({super.key, required this.count}); final int count; @override Widget build(BuildContext context) { dynamicChildBuildCount++; return Text('count: $count'); }}
class ParentWithSetState extends StatefulWidget { const ParentWithSetState({super.key}); @override State<ParentWithSetState> createState() => _ParentWithSetStateState();}
class _ParentWithSetStateState extends State<ParentWithSetState> { int count = 0; @override Widget build(BuildContext context) { return Column( children: [ const ConstChild(), DynamicChild(count: count), ElevatedButton( onPressed: () => setState(() => count++), child: const Text('increment'), ), ], ); }}import 'package:flutter/material.dart'; // re-exports widgets.dart, which declares debugPrintRebuildDirtyWidgets and debugPrintimport 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__rebuilds-and-performance/rebuild_counters.dart';
void main() { tearDown(() { debugPrintRebuildDirtyWidgets = false; });
testWidgets( 'debugPrintRebuildDirtyWidgets logs DynamicChild but never ConstChild after setState', (tester) async { final logs = <String>[]; final originalDebugPrint = debugPrint; debugPrint = (String? message, {int? wrapWidth}) { if (message != null) logs.add(message); }; debugPrintRebuildDirtyWidgets = true;
await tester.pumpWidget(const MaterialApp(home: ParentWithSetState())); logs.clear(); // discard the initial "Building ..." logs from first mount
await tester.tap(find.byType(ElevatedButton)); await tester.pump();
debugPrint = originalDebugPrint; debugPrintRebuildDirtyWidgets = false; // reset before the test ends -- the // test binding asserts this flag is unset when a test finishes, so // resetting only in tearDown() is too late.
final relevant = logs.where((l) => l.contains('ConstChild') || l.contains('DynamicChild')); expect(relevant.any((l) => l.contains('Rebuilding DynamicChild')), isTrue); expect(relevant.any((l) => l.contains('ConstChild')), isFalse); });
testWidgets('build-counter methodology: const child stays at 1, dynamic sibling increments', (tester) async { constChildBuildCount = 0; dynamicChildBuildCount = 0; await tester.pumpWidget(const MaterialApp(home: ParentWithSetState())); expect(constChildBuildCount, 1); expect(dynamicChildBuildCount, 1);
await tester.tap(find.byType(ElevatedButton)); await tester.pump();
expect(constChildBuildCount, 1); // unchanged -- const skip expect(dynamicChildBuildCount, 2); // rebuilt });}00:00 +0: debugPrintRebuildDirtyWidgets logs DynamicChild but never ConstChild after setState00:00 +1: build-counter methodology: const child stays at 1, dynamic sibling increments
00:00 +2: All tests passed!The two techniques agree, but the build-counter one (a plain int incremented inside build()) is what actually scales to a real test suite: it’s a precise, filterable assertion instead of a wall of framework-internal noise you have to eyeball. debugPrintRebuildDirtyWidgets (and DevTools’ own Performance tab / Widget Rebuild Stats, covered below) are for interactive, one-off investigation on a real device — this course has none, so every claim in this lesson uses the counter technique instead, and it is provably just as accurate: constChildBuildCount genuinely never moved past 1.
DevTools: Performance tab and Widget Rebuild Stats (docs — no device here)
Section titled “DevTools: Performance tab and Widget Rebuild Stats (docs — no device here)”On a real device/emulator (which this course’s environment does not have — no flutter run, no DevTools session), Flutter DevTools’ Performance view shows a live frame timeline (each frame’s build/raster time, with frames over budget flagged), and its Widget Rebuild Stats panel — documented at docs.flutter.dev — shows exactly which widgets rebuilt on each frame and how many times, which is debugPrintRebuildDirtyWidgets’s output turned into a real, filterable, per-frame UI instead of a raw print log. The workflow the docs describe: reproduce the jank, open the timeline, find the frame over budget, then check Widget Rebuild Stats for that frame to see which subtree did unnecessary work.
const: skip the rebuild entirely
Section titled “const: skip the rebuild entirely”Proven above: a const widget is canonicalized at compile time, and when a parent rebuilds, Flutter reuses the identical instance and skips calling build() on it altogether — constChildBuildCount stayed at 1 through a setState that rebuilt its parent. Turn on the prefer_const_constructors lint and obey it — it is the cheapest, most mechanical performance win available. One real correction worth making here: this lint is not part of flutter_lints’ default rule set (confirmed by reading flutter_lints 6.0.0’s own lib/flutter.yaml — it ships only the narrower prefer_const_constructors_in_immutables); very_good_analysis 11.0.0 does include the general prefer_const_constructors rule out of the box. Both lint sets, and this exact gap, are covered in the project-and-tooling lesson.
Narrowing scope: ValueListenableBuilder (and Riverpod’s select)
Section titled “Narrowing scope: ValueListenableBuilder (and Riverpod’s select)”setState rebuilds the whole enclosing State’s build output. ValueListenableBuilder narrows that: only its own builder callback re-runs when the ValueListenable changes — the surrounding State.build() does not re-run at all.
import 'package:flutter/material.dart';
int scopedParentBuildCount = 0;int scopedChildBuildCount = 0;
class ScopedCounter extends StatefulWidget { const ScopedCounter({super.key}); @override State<ScopedCounter> createState() => _ScopedCounterState();}
class _ScopedCounterState extends State<ScopedCounter> { final ValueNotifier<int> notifier = ValueNotifier<int>(0);
@override void dispose() { notifier.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { scopedParentBuildCount++; return Column( children: [ ValueListenableBuilder<int>( valueListenable: notifier, builder: (context, value, child) { scopedChildBuildCount++; return Text('value: $value'); }, ), ElevatedButton( onPressed: () => notifier.value++, child: const Text('increment'), ), ], ); }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__rebuilds-and-performance/vlb_scope_demo.dart';
void main() { testWidgets( 'ValueListenableBuilder rebuilds only its own builder, not the parent State', (tester) async { scopedParentBuildCount = 0; scopedChildBuildCount = 0; await tester.pumpWidget(const MaterialApp(home: ScopedCounter())); expect(scopedParentBuildCount, 1); expect(scopedChildBuildCount, 1);
await tester.tap(find.byType(ElevatedButton)); await tester.pump();
expect(scopedParentBuildCount, 1); // the State's build() never re-ran expect(scopedChildBuildCount, 2); // only the builder callback re-ran });}00:00 +0: ValueListenableBuilder rebuilds only its own builder, not the parent State
00:00 +1: All tests passed!scopedParentBuildCount staying at 1 after the notifier changed is the proof: the whole point of ValueListenableBuilder (and, in the Riverpod world, ref.watch(provider.select(...))) is to move the rebuild boundary down to exactly the widget that needs the new value, instead of the entire enclosing State. Riverpod’s .select() mechanics (with its own proof) are covered in the Provider & Riverpod lesson — the scoping principle proven here with ValueListenableBuilder is the same one .select() applies at the provider layer.
ListView.builder vs. ListView(children:), proven genuinely lazy
Section titled “ListView.builder vs. ListView(children:), proven genuinely lazy”The common claim — “ListView.builder only builds visible items, ListView(children:) builds everything” — needs a precise proof, because a naive build-counter test (counting build() calls) actually gets this wrong: RenderSliverList only mounts elements within the visible range plus cache extent regardless of which delegate feeds it, so build() call counts end up nearly identical for both. The real, honest difference is one step earlier — in how many Widget objects get constructed in the first place:
import 'package:flutter/material.dart';
int itemConstructedCount = 0;
class CountedItem extends StatelessWidget { CountedItem(this.index, {super.key}) { itemConstructedCount++; // counts constructor calls, not build() calls } final int index; @override Widget build(BuildContext context) => SizedBox(height: 50, child: Text('item $index'));}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__rebuilds-and-performance/listview_laziness.dart';
void main() { testWidgets( 'ListView(children:) constructs every item eagerly; ListView.builder only constructs visible ones', (tester) async { itemConstructedCount = 0; await tester.pumpWidget(MaterialApp( home: SizedBox( height: 300, child: ListView( children: List.generate(100, (i) => CountedItem(i)), // eager: calls CountedItem() 100 times right here ), ), )); expect(itemConstructedCount, 100);
itemConstructedCount = 0; await tester.pumpWidget(MaterialApp( home: SizedBox( height: 300, child: ListView.builder( itemCount: 100, itemBuilder: (context, i) => CountedItem(i), // lazy: only called for what's needed ), ), )); expect(itemConstructedCount, lessThan(100)); expect(itemConstructedCount, greaterThan(0)); });}00:00 +0: ListView(children:) constructs every item eagerly; ListView.builder only constructs visible ones
00:00 +1: All tests passed!Measured on this Mac: ListView(children:) constructed all 100 items (List.generate(100, ...) is plain synchronous Dart — it has no way to know only some will be visible); ListView.builder, in the same 300-logical-pixel viewport, constructed only 17 (the visible rows plus SliverList’s default cache extent) — a real, ~6x difference for a list this size, and one that scales linearly with list length for ListView(children:) while staying roughly constant for .builder. The exact “17” is this Mac’s default cache extent at this viewport size, not a guaranteed number — the point proven is bounded-by-viewport vs. bounded-by-list-length, not the specific count. For the sliver mechanics underneath both delegates (SliverList.builder, viewport/cache-extent internals), see the layout-and-rendering module.
Opacity, ClipRRect, and saveLayer costs
Section titled “Opacity, ClipRRect, and saveLayer costs”Both Opacity (for a non-1.0, non-0.0 value on a child that isn’t already trivially transparent) and a default ClipRRect can force Flutter to allocate an offscreen layer (Canvas.saveLayer) to composite correctly — a real GPU cost, not a hypothetical one. The Opacity class’s own doc comment is direct about it, recommending AnimatedOpacity/FadeTransition instead where the value is genuinely animating, and calling out that “Opacity is a relatively expensive widget because it requires compositing” for the non-trivial case. This lesson doesn’t re-derive the paint/composite pipeline itself — the render-pipeline lesson covers layers and saveLayer in depth; the actionable version here is: an Opacity/ClipRRect sitting inside something that repaints every frame (an active animation) compounds a saveLayer cost on every one of those frames, which is exactly the kind of thing a RepaintBoundary (covered in the animations lesson) contains rather than eliminates.
shouldRepaint in CustomPainter, proven
Section titled “shouldRepaint in CustomPainter, proven”A CustomPainter’s paint() is expensive to call unnecessarily — shouldRepaint is the hook that decides whether a new paint() call actually happens when the painter is replaced:
import 'package:flutter/material.dart';
int paintCallCount = 0;
class CountingPainter extends CustomPainter { CountingPainter(this.value); final int value;
@override void paint(Canvas canvas, Size size) { paintCallCount++; canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()); }
@override bool shouldRepaint(covariant CountingPainter oldDelegate) { return oldDelegate.value != value; }}import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/async-and-performance__rebuilds-and-performance/custom_painter_demo.dart';
void main() { testWidgets('shouldRepaint returning false skips the next paint() call', (tester) async { paintCallCount = 0; Widget build(int value) => MaterialApp( home: CustomPaint(painter: CountingPainter(value), size: const Size(100, 100)), );
await tester.pumpWidget(build(1)); expect(paintCallCount, 1);
await tester.pumpWidget(build(1)); // same value -- shouldRepaint returns false expect(paintCallCount, 1); // no new paint() call
await tester.pumpWidget(build(2)); // different value -- shouldRepaint returns true expect(paintCallCount, 2); });}00:00 +0: shouldRepaint returning false skips the next paint() call
00:00 +1: All tests passed!paintCallCount staying at 1 across the same-value replacement is the proof: Flutter creates a new CountingPainter instance on every build, but only calls its paint() if shouldRepaint(oldDelegate) says to. Returning oldDelegate.value != value — comparing against the actual old painter, not a global/static flag — is what makes this correct across repeated identical rebuilds, not just the first one.
Image caching: cacheWidth/cacheHeight
Section titled “Image caching: cacheWidth/cacheHeight”Decoding an image at its native resolution when it will only ever render at a much smaller size wastes both decode time and cached memory — Image(..., cacheWidth: 200) (or the equivalent on ImageProvider.resolve) tells Flutter’s image decoder to decode directly at that target resolution instead of decoding full-size and downscaling afterward, which the Image class’s own docs describe as reducing “the memory usage of the ImageCache.” This lesson does not run a decode-memory proof — this headless probe has no real image asset to decode, and constructing one synthetically (a minimal PNG byte buffer) would prove the codec API works, not the actual memory savings claim — so this one is cited from docs rather than measured, honestly, rather than faked with a number that wouldn’t mean anything.
Shader jank and Impeller
Section titled “Shader jank and Impeller”A shader that hasn’t been compiled yet causes a real, visible stutter the first time it’s used — “shader compilation jank.” This is a render-pipeline-level concern (precompiled pipeline state objects under Impeller vs. runtime shader compilation under Skia) covered in depth, with the actual mechanism and version-stamped Impeller-default status, in the render-pipeline lesson — not re-derived here to avoid duplicating that proof.
Profiling on a real device: flutter run --profile, --trace-skia, --trace-systrace (docs — no device here)
Section titled “Profiling on a real device: flutter run --profile, --trace-skia, --trace-systrace (docs — no device here)”This course’s environment has no device or emulator, so none of the following can be run and captured here — cited honestly from docs.flutter.dev’s performance profiling guide instead: flutter run --profile builds a profile-mode build (closer to release performance than debug, but with tracing enabled) — this is the mode DevTools’ Performance timeline is meant to be used against, since debug-mode timing includes assertion and debug-service overhead that doesn’t reflect real-world frame times. --trace-skia and --trace-systrace (documented flags for capturing lower-level rendering/OS-level traces) feed platform-native profiling tools (Android’s Perfetto/systrace, or Skia’s own trace viewer) for cases where DevTools’ own timeline isn’t granular enough.
Frame budget
Section titled “Frame budget”At 60fps, each frame has 16.67ms (1000ms ÷ 60) to complete build + layout + paint + raster before the next one is due; at 120fps (many current phones), that budget halves to 8.33ms. Every technique in this lesson — skipping a rebuild with const, narrowing scope with ValueListenableBuilder, keeping ListView lazy, skipping an unnecessary paint() via shouldRepaint — exists to keep a frame’s actual work under that number; DevTools’ Performance timeline (above) is how you find out, on a real device, which frames actually go over it and why.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated “performance-conscious” Flutter code reliably reaches for const in the easy, already-obvious spots and misses the two places that actually matter at scale: list construction, and custom painting.
// AI-generated: "show a scrollable list of 500 items"class ItemsScreen extends StatelessWidget { const ItemsScreen({super.key, required this.items}); final List<Item> items;
@override Widget build(BuildContext context) { return ListView( children: items.map((item) => ListTile(title: Text(item.name))).toList(), ); }}Before trusting generated list/painting code like this, check for:
ListView(children: someList.map(...).toList())for anything that could be long or come from a server — proven above, this constructs every single item widget immediately, regardless of how many are ever visible;ListView.builder(itemCount:, itemBuilder:)is the fix, proven above to construct only what the viewport (plus cache extent) actually needs.- A
CustomPainterwith noshouldRepaintoverride (the default implementation returnstrueunconditionally in most generated code, or is simply omitted, which has the same effect) — proven above, this means every single rebuild that reaches theCustomPaintre-runspaint()even when nothing the painter reads actually changed. Opacity/ClipRRectwrapped directly around something that rebuilds every frame (inside anAnimatedBuilder, for instance) with noRepaintBoundary— compounds asaveLayercost on every frame instead of containing it.- A full-resolution
Imagerendered at a small fixed size with nocacheWidth/cacheHeight— decodes and caches far more pixel data than will ever actually be displayed.
🐛 Spot the bug
class Heatmap extends StatelessWidget {const Heatmap({super.key, required this.values});final List<double> values;
@overrideWidget build(BuildContext context) { return CustomPaint( painter: HeatmapPainter(values), size: const Size(300, 300), );}}
class HeatmapPainter extends CustomPainter {HeatmapPainter(this.values);final List<double> values;
@overridevoid paint(Canvas canvas, Size size) { for (var i = 0; i < values.length; i++) { canvas.drawRect( Rect.fromLTWH(i * 10.0, 0, 10, size.height * values[i]), Paint()..color = Colors.red.withValues(alpha: values[i]), ); }}
@overridebool shouldRepaint(covariant HeatmapPainter oldDelegate) => true;}Show the answer
Proven above with the identical mechanism: shouldRepaint unconditionally returns true, so every single time Heatmap rebuilds — even if values is the exact same list with the exact same contents — HeatmapPainter.paint() re-runs the whole loop and repaints every rectangle from scratch. For a large values list rebuilt often (e.g., inside a scrolling list, or alongside an unrelated animation elsewhere on the screen), this is real, avoidable repaint cost every frame. The fix, proven above: compare against oldDelegate.values (e.g., !listEquals(oldDelegate.values, values), or track a separate version/revision number if the list is large enough that a full equality check itself gets expensive) and only return true when the data actually changed.
Mental Model
Section titled “Mental Model”Every technique in this lesson is really the same move applied at a different layer: narrow the boundary of “what has to redo work” as close as possible to what actually changed. const narrows it to “nothing” (compile-time-identical input, zero work). ValueListenableBuilder/.select() narrow it from “the whole State” to “the one widget reading this one value.” ListView.builder narrows “which items get constructed” from “the whole list” to “what the viewport can see.” shouldRepaint narrows “which frames actually repaint” from “every rebuild” to “the ones where the painter’s own inputs changed.” None of these are separate tricks — they’re the same discipline, applied everywhere a rebuild/repaint boundary exists in Flutter’s tree.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
const everywhere possible | Proven above: skips build() entirely on parent rebuild, for free | None real — only requires the widget’s inputs to genuinely be compile-time constant |
ValueListenableBuilder/.select() | Proven above: narrows rebuild scope from the whole State to one widget | An extra layer of indirection/boilerplate per scoped value |
ListView.builder | Proven above: genuinely bounded by viewport, not list length | Slightly more setup (itemCount/itemBuilder) than a literal children: list |
shouldRepaint doing real comparison | Proven above: skips paint() entirely when nothing relevant changed | The comparison itself has a cost — for very large data, a version/revision counter can beat a deep equality check |
| Measuring with a build-counter instead of DevTools | Precise, assertable, works headlessly (proven throughout this lesson) | Requires adding instrumentation to the code being measured; DevTools needs no code changes but needs a real device |
Common Misconceptions
Section titled “Common Misconceptions”- “
ListView.builderbuilds fewer widgets because it’s smarter about diffing.” Disproven above: both delegates feed the sameRenderSliverList, which mounts the same viewport-plus-cache-extent range of elements either way — the real, proven difference is thatListView(children:)’sList.generate/.map().toList()step constructs every widget OBJECT eagerly before the sliver ever sees them, while.builder’sitemBuilderclosure is only invoked for what’s actually needed. - “A
CustomPainterwithout ashouldRepaintoverride is safe by default.” Disproven above: the common pattern (=> trueor an omitted override, which effectively also returnstrue) meanspaint()re-runs on every single rebuild, proven above to be real, avoidable repaint cost. - “Measuring rebuilds requires a real device and DevTools.” Disproven throughout this lesson: every claim above was proven headlessly, in this exact environment, with a plain build-counter
intandtester.pump()— DevTools is a convenience for interactive, on-device investigation, not the only way to get a real number. - “
ValueListenableBuilderis just a fanciersetState.” Disproven above:setStaterebuilds the entire enclosingState.build();ValueListenableBuilder’s parentStatenever rebuilds at all when the notifier changes — proven above byscopedParentBuildCountstaying at1.