Debugging Flutter's Widget Rebuilds

April 23, 20265 min readBy Sinnoor C

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 ChangeNotifier per page rebuilds every listener; one ValueNotifier per 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.
SymptomRoot causeFixVerification
Static widget rebuilds on every scrollInheritedWidget dirtied at rootScope provider to subtreeRebuild count drops to 1 in DevTools
Whole page rebuilds on a single field changeCoarse ChangeNotifier notifying every listenerSplit into per-field ValueNotifiersOnly the dependent widget rebuilds
Subtree rebuilds despite unchanged propsMissing const constructorsAdd const to constructors + child literals + lint prefer_const_constructors as errorDevTools shows 1 mount, 0 follow-up rebuilds
Theme.of(context) widget rebuilds on unrelated theme tweakInheritedWidget dirties on any field change, not just fields you readRead narrower theme extension or scope to a child ThemeProfile post-tweak shows targeted widget did not rebuild
AnimatedBuilder rebuilding entire subtree per frameWrapping too much in the builderWrap only the animated leafDevTools 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.

main.dart
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:

TriggerFrequency in prod Flutter appsTypical fix
InheritedWidget updating at rootVery highScope provider to subtree
setState in a page-level StatefulWidgetHighLift state to leaf, pass ValueNotifier
StreamBuilder emitting on every frameMediumDebounce / .distinct() upstream
ValueListenableBuilder with a coarse listenableMediumSplit into fine-grained notifiers
AnimatedBuilder wrapping too muchLowWrap 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.

my_widget.dart
// 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:

cart_notifier.dart
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.