You have a screen with a counter at the top and a grid of 300 cards below it. The counter updates every 500 ms. Nothing else on the screen depends on that number — the cards are static, they never change. And yet, every half second, the whole grid does work: it flickers with rebuilds you never asked for.
The counter is not the problem. Where you put the setState is. This is the most
common performance mistake in Flutter, and the first thing DevTools teaches you to see.
This is Part 1 of a four-part series on diagnosing Flutter performance with DevTools. We start here because rebuilds are the cheapest problem to cause and the easiest one to see once you know where to look.
The screen below is Demo 1 of
performance_demo, the open-source
playground I walk through in its own post — clone it if
you’d rather reproduce the readings yourself than read them. The applied fixes live on its
solutions branch.
The setup
Here’s the full screen — a timer bumps a value every 500 ms and shows it above a grid of
cards. It’s a complete file: drop it into lib/, set home: const Demo1Rebuilds() in your
MaterialApp, and run it to follow along.
import 'dart:async';
import 'package:flutter/material.dart';
class Demo1Rebuilds extends StatefulWidget {
const Demo1Rebuilds({super.key});
@override
State<Demo1Rebuilds> createState() => _Demo1RebuildsState();
}
class _Demo1RebuildsState extends State<Demo1Rebuilds> {
Timer? _timer;
int _value = 0;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
setState(() => _value++); // rebuilds the ENTIRE screen
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter panel')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Value: $_value',
style: Theme.of(context).textTheme.headlineMedium,
),
),
const Divider(height: 1),
Expanded(
// Does not depend on `_value`, but rebuilds in full on every tick
// because the setState is at the root of the screen.
child: GridView.count(
crossAxisCount: 3,
padding: const EdgeInsets.all(8),
children: List.generate(300, (i) => MetricCard(index: i)),
),
),
],
),
);
}
}
// Deliberately not `const` — see Fix 2 for why that matters less than it looks.
class MetricCard extends StatelessWidget {
// ignore: prefer_const_constructors_in_immutables
MetricCard({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.insights, color: Colors.indigo.shade300),
const SizedBox(height: 4),
Text('Metric $index'),
],
),
),
);
}
}
It works. It also burns CPU 120 times a minute rebuilding cards that never change.
Seeing it in DevTools
You don’t have to guess. This is exactly what the Performance view is for.
Debug mode. Rebuild tracking only exists in debug builds — it relies on
track-widget-creation, which profile and release strip out. Run withflutter run --debugto hunt rebuilds. (We measure timings in profile later; for now we only want to count rebuilds.) And don’t confuse Count widget builds with Trace widget builds in the Enhance Tracing dropdown: the first produces the counts table below, the second adds per-widgetBuildevents to the timeline. Similar names, different jobs.
Open DevTools, go to Performance, switch to the Rebuild Stats tab, and tick Count widget builds. Then watch a few ticks go by:

Three columns matter here. Latest frame is how many times that widget was built in the
most recent frame, Overall is the running total since you hit Clear, and Location
is the call site where it was created. That last column is truncated in the screenshot,
which is why all three Text rows look identical — widen it and you get the line number,
the only thing that separates the card’s Text from the Value: one from the AppBar
title.
Read that way, the table splits into three tiers:
MetricCard,Card,Icon, and the cards’Text— 24 per frame, 936 total.Demo1Rebuilds,Scaffold,GridView,Expanded,AppBar, theValue:Text— 1 per frame, 39 total.Dividerand the AppBar’sText— 0 per frame, 1 total. Remember these two; they are Fix 2 already at work. (A third row sits at 0/1 too — a secondAppBar, located inhome_screen. That one belongs to the menu screen hosting this demo, not to the screen we’re profiling.)
The middle tier is the tick count: 39 frames, and the emulator reads Value: 38. The top
tier is 24 × 39 = 936 builds of cards that never changed. That’s your smoking gun.
Note that it’s 24 per tick, not 300. GridView is lazy: it only builds the children
that are on screen (plus a small cache margin), so most of your 300 cards are never built
at all. The number you see depends on the viewport — it would be higher on a tablet, and
it would be the full 300 if this were a Column inside a SingleChildScrollView. What
does happen 300 times per tick is List.generate allocating 300 new MetricCard
objects; only the visible ones get a build() call. Counting the rows in the table
instead of the widgets in your code is the difference between measuring and guessing —
and it’s a habit worth forming now, because
Part 4 is entirely about DevTools readings that
look worse than they are.
Why it happens
When you call setState, Flutter marks the widget that owns that state as dirty and
re-runs its build(). The catch: build() returns a whole subtree, and everything it
returns is rebuilt with it.
Because the setState here lives in _Demo1RebuildsState — the root of the screen — the
subtree it revisits is the entire screen: the Text, the GridView, and every
MetricCard the grid is currently showing. Flutter has no way to know that only the
Text actually cares about _value. You told it the root changed, so the root and
everything below it is walked again.
Two widgets escape, and they’re sitting right there in the table at Overall 1: the
Divider and the AppBar’s title. Hold that thought — it’s Fix 2.
The fix, then, is not to make rebuilds faster. It’s to make them smaller — to rebuild only the piece that actually changed.
Fix 1: isolate the state that changes
Instead of holding the value in the screen’s State and calling setState at the root,
put the value in a ValueNotifier and let a ValueListenableBuilder rebuild only the
Text:
class _Demo1RebuildsState extends State<Demo1Rebuilds> {
Timer? _timer;
// The changing value lives in its own ValueNotifier so only the widget that
// depends on it rebuilds — not the whole screen.
final _value = ValueNotifier<int>(0);
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
_value.value++; // no setState — only the listener below reacts
});
}
@override
void dispose() {
_timer?.cancel();
_value.dispose(); // you created it, so you dispose it (see Part 3)
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter panel')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
// Only this builder reacts to the ticker; the rest of the tree
// is left untouched on each tick.
child: ValueListenableBuilder<int>(
valueListenable: _value,
builder: (context, value, child) => Text(
'Value: $value',
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
const Divider(height: 1),
Expanded(
child: GridView.count(
crossAxisCount: 3,
padding: const EdgeInsets.all(8),
children: List.generate(300, (i) => MetricCard(index: i)),
),
),
],
),
);
}
}
There is no setState at the root anymore, so Flutter never revisits the GridView
subtree. The ticker updates a single Text. Re-run with Count widget builds and this is
what “fixed” looks like:

Now only ValueListenableBuilder and its Text move — 1 per frame, 75 total against
Value: 74. Everything else reads 0 in the Latest frame column, including
Demo1Rebuilds itself. MetricCard still shows 24 under Overall, and that’s correct:
those are the initial builds from when the screen opened. The number to watch is the one
that stopped climbing, not the one that reached zero.
Fix 2: hand Flutter the same instance
There’s a second lever, and it’s almost free. When Flutter rebuilds a parent, it compares
each new child widget against the one already mounted. If it’s the identical instance,
it stops there — the element isn’t updated and build() is never called on that subtree.
You can already see this working in the “before” screenshot. The AppBar rebuilds 39
times, but its title — const Text('Counter panel') — sits at Overall 1. Same for the
const Divider: everything around it rebuilds on every tick and it never does. Those two
rows are const earning its keep, in a screenshot of the broken version.
That’s what const buys you: the compiler canonicalizes the instance, so it’s literally
the same object on every rebuild. It’s not a style preference — it’s a signal to the
framework that a subtree can be reused as-is.
But const only works when every argument is a compile-time constant, and that’s
exactly where the cards fall down:
// Doesn't compile: `i` comes from List.generate at runtime.
// error • Invalid constant value
children: List.generate(300, (i) => const MetricCard(index: i)),
Making the constructor const doesn’t rescue it either — the constructor was never the
blocker, the argument is. This trips people up constantly, because “just add const”
is repeated so often that it sounds unconditional. It isn’t: a widget parameterized by
runtime data can’t be const, ever.
What you can always do is the thing const is a special case of — build the instances
once and hand back the same ones:
class _Demo1RebuildsState extends State<Demo1Rebuilds> {
// Built once, on first access — the same 300 instances from then on.
late final List<Widget> _cards =
List.generate(300, (i) => MetricCard(index: i));
@override
Widget build(BuildContext context) {
// ...everything else unchanged; only the grid's children differ:
return GridView.count(
crossAxisCount: 3,
padding: const EdgeInsets.all(8),
children: _cards, // <- was List.generate(300, ...) inline
);
}
}
Instrumented with a build counter, this drops the cards to 0 rebuilds per tick —
without Fix 1. The setState still fires at the root, the Scaffold and the GridView
still rebuild, and the cards are skipped anyway because nothing about them is new. It’s
the same short-circuit the const Divider gets, reached a different way.
The tradeoff is the obvious one: those instances are frozen. This is the right move for genuinely static content, and the wrong one the moment a card needs to reflect changing data — then you want Fix 1 applied inside the card, so each one owns the state it depends on.
A child rebuilds only if its parent rebuilds and hands it a new instance. Isolating state stops the parent from rebuilding; reusing instances — via
constwhen the arguments allow it, via a hoisted field when they don’t — protects the children when it does. You want both: Fix 1 makes the rebuild small, Fix 2 makes the leftovers cheap.
Rebuild is not repaint
One distinction to plant now, because it trips people up and it’s the bridge to Part 2:
- Rebuild =
build()runs and Flutter reconstructs the widget objects. You fix it by isolating state and by reusing instances, as we just did. - Repaint = the GPU actually redraws pixels on screen. You fix that with
RepaintBoundary, and it’s a different thread entirely.
A rebuild does not always mean a repaint, and a repaint does not always come from a rebuild. Chasing rebuilds when your problem is really painting — or vice versa — is how people spend an afternoon optimizing the wrong thing. Picking the right tool for the right symptom is the whole game, and it’s what the rest of this series is about.
Closing
The counter was never the problem. setState rebuilds the subtree of whoever owns the
state, so a setState at the root of the screen rebuilds the whole screen. Two moves fix
it: isolate the changing state so the rebuild is small, and reuse the instances of
the static parts so they’re skipped when a rebuild does happen — const where the
arguments are constant, a hoisted field where they aren’t. DevTools’ “Track widget
rebuilds” turns all of this from guesswork into something you can watch climb — or stop.
Rebuilds are the cheap problem. In Part 2 we go after a more expensive one: heavy work that blocks the UI thread and drops frames on every keystroke — how to read the frames chart to tell whether your jank comes from your Dart code or from the GPU, and then how to use the CPU Profiler to pin down the exact code responsible.

