TL;DR — A 5-step workflow to kill Flutter rebuild storms in minutes:
- Track Widget Rebuilds in DevTools — anything static that rebuilds with scroll position is leaking.
- Identify the trigger — InheritedWidget at root, page-level
setState, or coarse Listenables are the usual suspects.- Isolate with
const— fixes ~60% of cases by handing Flutter canonical instances it can compare-and-skip.- Split notifiers — one
ChangeNotifierper page rebuilds every listener; oneValueNotifierper concern rebuilds only what changed.- Verify in the timeline — re-profile after every fix; never assume the first thing you suspect was the real trigger.
| Symptom | Root cause | Fix | Verification |
|---|---|---|---|
| Static widget rebuilds on every scroll | InheritedWidget dirtied at root | Scope provider to subtree | Rebuild count drops to 1 in DevTools |
| Whole page rebuilds on a single field change | Coarse ChangeNotifier notifying every listener | Split into per-field ValueNotifiers | Only the dependent widget rebuilds |
| Subtree rebuilds despite unchanged props | Missing const constructors | Add const to constructors + child literals + lint prefer_const_constructors as error | DevTools shows 1 mount, 0 follow-up rebuilds |
Theme.of(context) widget rebuilds on unrelated theme tweak | InheritedWidget dirties on any field change, not just fields you read | Read narrower theme extension or scope to a child Theme | Profile post-tweak shows targeted widget did not rebuild |
AnimatedBuilder rebuilding entire subtree per frame | Wrapping too much in the builder | Wrap only the animated leaf | DevTools rebuild counter drops to the animated subtree only |
Every Flutter app I've shipped past a certain size has hit the same wall: screen gets janky, profiling shows CPU pinned at 90% during idle scroll, and somewhere a ListView is repainting the entire world on every tick. The culprit is almost always the same — a rebuild storm caused by a widget higher up the tree calling setState (or a Listenable firing) and taking a chunk of the subtree with it that had no business rebuilding.
This post is the workflow I run whenever a Flutter build profile shows unexplained frame drops. It assumes you know what a BuildContext is and have used the Flutter DevTools at least once.
Step 1: Turn on rebuild highlighting
This is the fastest, highest-leverage debug switch Flutter ships. From the Performance tab in DevTools, toggle "Track Widget Rebuilds". Then run your app and interact with the page you suspect. The counter tells you exactly how many times each widget rebuilt during that session.
void main() {
// Enable rebuild profiling in debug builds only.
debugProfileBuildsEnabled = true;
debugProfileLayoutsEnabled = true;
runApp(const MyApp());
}In a healthy app, static widgets (your AppBar, a sidebar, a settings row) should show 1 rebuild for the initial mount and nothing more. If anything static is rebuilding with the scroll position, you have a leak.
Step 2: Identify the trigger
The most common trigger sources, ranked by how often I've seen them cause pain:
| Trigger | Frequency in prod Flutter apps | Typical fix |
|---|---|---|
InheritedWidget updating at root | Very high | Scope provider to subtree |
setState in a page-level StatefulWidget | High | Lift state to leaf, pass ValueNotifier |
StreamBuilder emitting on every frame | Medium | Debounce / .distinct() upstream |
ValueListenableBuilder with a coarse listenable | Medium | Split into fine-grained notifiers |
AnimatedBuilder wrapping too much | Low | Wrap only the animated subtree |
A subtle one I hit recently: a Theme.of(context) call inside a widget that was otherwise pure. When I changed the theme extension in a tangential spot, every Scaffold in the app rebuilt. Root cause: InheritedWidget dependencies are dirtied on any change, even fields your widget doesn't read.
Step 3: Isolate with const
The fix for 60% of the cases I see is const. Dart's const constructors create canonical instances — if the widget below hasn't changed, Flutter skips the subtree entirely because the element compares equal.
// Bad — rebuilds `Padding` + `Text` every time parent rebuilds.
class Title extends StatelessWidget {
const Title({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16),
child: Text(text, style: TextStyle(fontWeight: FontWeight.bold)),
);
}
}
// Good — everything that can be const, is const.
class Title extends StatelessWidget {
const Title({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
text,
style: const TextStyle(fontWeight: FontWeight.bold),
),
);
}
}Dart analyzer lint prefer_const_constructors will flag most of these automatically. Turn it on as an error, not a warning, and never let a PR land with const violations.
Step 4: Split notifiers
When you DO need state that updates, the instinct is to reach for one big ChangeNotifier holding the whole page state. Don't. Each listener rebuilds on every notify — even if only one field changed.
Instead, expose each independently-rebuildable thing as its own ValueNotifier, and use ValueListenableBuilder to subscribe:
class CartNotifier {
final ValueNotifier<int> itemCount = ValueNotifier(0);
final ValueNotifier<double> total = ValueNotifier(0);
final ValueNotifier<bool> isCheckingOut = ValueNotifier(false);
}The checkout button subscribes to isCheckingOut only; the cart badge subscribes to itemCount only. Changes to total don't touch either.
Step 5: Verify in the timeline
After every fix, re-run with rebuild tracking on. If a widget that was rebuilding 60 times per frame now rebuilds once, you've won. If it's still rebuilding, go back to step 2 — you haven't found the real trigger yet.
The mistake I made for years was fixing the first thing I suspected and moving on. Now I always re-verify. Rebuild storms are like bugs in a parser: the thing you think is wrong usually isn't.