Skip to content

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.

lib/rebuild_counters.dart
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'),
),
],
);
}
}
test/rebuild_counters_test.dart
import 'package:flutter/material.dart'; // re-exports widgets.dart, which declares debugPrintRebuildDirtyWidgets and debugPrint
import '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 setState
00: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.

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.

lib/vlb_scope_demo.dart
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'),
),
],
);
}
}
test/vlb_scope_demo_test.dart
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:

lib/listview_laziness.dart
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'));
}
test/listview_laziness_test.dart
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.

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.

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:

lib/custom_painter_demo.dart
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;
}
}
test/custom_painter_demo_test.dart
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.

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.

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.

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.

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 CustomPainter with no shouldRepaint override (the default implementation returns true unconditionally in most generated code, or is simply omitted, which has the same effect) — proven above, this means every single rebuild that reaches the CustomPaint re-runs paint() even when nothing the painter reads actually changed.
  • Opacity/ClipRRect wrapped directly around something that rebuilds every frame (inside an AnimatedBuilder, for instance) with no RepaintBoundary — compounds a saveLayer cost on every frame instead of containing it.
  • A full-resolution Image rendered at a small fixed size with no cacheWidth/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;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: HeatmapPainter(values),
size: const Size(300, 300),
);
}
}
class HeatmapPainter extends CustomPainter {
HeatmapPainter(this.values);
final List<double> values;
@override
void 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]),
);
}
}
@override
bool 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.

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.

ChoiceBenefitCost
const everywhere possibleProven above: skips build() entirely on parent rebuild, for freeNone 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 widgetAn extra layer of indirection/boilerplate per scoped value
ListView.builderProven above: genuinely bounded by viewport, not list lengthSlightly more setup (itemCount/itemBuilder) than a literal children: list
shouldRepaint doing real comparisonProven above: skips paint() entirely when nothing relevant changedThe 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 DevToolsPrecise, 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
  • “ListView.builder builds fewer widgets because it’s smarter about diffing.” Disproven above: both delegates feed the same RenderSliverList, which mounts the same viewport-plus-cache-extent range of elements either way — the real, proven difference is that ListView(children:)’s List.generate/.map().toList() step constructs every widget OBJECT eagerly before the sliver ever sees them, while .builder’s itemBuilder closure is only invoked for what’s actually needed.
  • “A CustomPainter without a shouldRepaint override is safe by default.” Disproven above: the common pattern (=> true or an omitted override, which effectively also returns true) means paint() 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 int and tester.pump() — DevTools is a convenience for interactive, on-device investigation, not the only way to get a real number.
  • “ValueListenableBuilder is just a fancier setState.” Disproven above: setState rebuilds the entire enclosing State.build(); ValueListenableBuilder’s parent State never rebuilds at all when the notifier changes — proven above by scopedParentBuildCount staying at 1.
Why does a const widget help performance?
Proven above, what did the honest, filtered debugPrintRebuildDirtyWidgets output and the build-counter test agree on?
Proven above, what is the real, precise difference between ListView.builder and ListView(children:)?
Proven above with a real test, what does CustomPainter.shouldRepaint actually control?
Proven above, what does ValueListenableBuilder change about which widgets rebuild on a value change?
What is the actual frame budget at 60fps, and why does every technique in this lesson exist relative to it?