The Performance tab in DevTools flagged it first: a single heart-rate chip in Kompete’s live match screen was rebuilding 40+ times a second during an active session, even though its underlying value changed maybe twice a second. Nothing was visibly wrong — no dropped frames a user would consciously notice — but the raster thread was doing work it had no reason to do, and on the older Android devices in our test pool that margin matters. So we spent a week doing something most teams skip because it’s tedious: turning on “Track Widget Rebuilds,” letting the app run through real usage sessions, and reading the counts instead of guessing.

Kompete is a competitive fitness/sports matching app with a lot of live state — active match scores, timers, leaderboard positions, presence indicators — all updating concurrently while the rest of the screen (headers, tab bars, static player cards) shouldn’t move at all. That combination is exactly where Flutter rebuild waste hides, because it’s easy to write correct code that’s also wasteful code. The widget tree doesn’t tell you which parts are cheap to rebuild and which parts aren’t; DevTools does.

What the profiler actually showed

Three patterns accounted for almost all of the waste we found, and none of them were exotic:

  • Provider/ChangeNotifier scoped too wide. A single `MatchState` notifier held score, timer, and roster data together. Any widget consuming that notifier via `context.watch<MatchState>()` rebuilt on every field change, not just the field it displayed. The timer ticking once a second was silently rebuilding the roster list too.
  • Missing `const` on static subtrees. Header rows, icon buttons, and static labels inside frequently-rebuilding parents weren’t marked `const`, so Flutter couldn’t skip them even though their constructor arguments never changed. This is the cheapest fix in the list and the one we found most often — it’s easy to lose track of `const` correctness as a widget tree grows and gets refactored by multiple people over months.
  • Rebuild-triggering widgets placed too high in the tree. A `StreamBuilder` wrapping an entire match card — instead of just the score text inside it — meant every score tick rebuilt player avatars, layout containers, and gesture detectors that had nothing to do with the score.

None of these are bugs in the sense of producing wrong output. The app worked correctly the entire time. That’s what makes this category of problem easy to ship and easy to ignore — it costs battery and frame budget, not correctness, so it doesn’t show up in a bug report. It shows up as “the app feels a little warm” or “scrolling stutters on the match screen sometimes,” reports that are hard to act on without a profiler pointing at the exact widget.

The fixes, and what they actually bought us

The fix for the wide notifier was to split `MatchState` into `ScoreState`, `TimerState`, and `RosterState`, each with its own `ChangeNotifier`, and to use `Selector` instead of a blanket `context.watch` wherever a widget only needed one field. `Selector`’s comparison function meant a widget watching `score.home` stopped rebuilding when `score.timer` changed, even though both lived one level removed from each other in the state graph. This was the highest-leverage change of the week — it directly cut the roster list’s rebuild count from once-per-second to once-per-actual-roster-change, which in a match session is close to zero.

The `const` sweep was mechanical but not free: it meant auditing constructors to make sure nothing non-const was sneaking into what should have been a static subtree (a common offender is a callback closure created inline in `build()`, which defeats `const` even when the visible widget looks static). We moved several of those closures to methods bound once, rather than lambdas rebuilt every frame.

For the `StreamBuilder` scope problem, we pulled the stream subscription down to wrap only the `Text` widget rendering the score, and left the card’s layout, avatar, and static metadata outside the builder entirely. The rebuild count for the card as a whole dropped correspondingly, and — more usefully for debugging in DevTools — the widget rebuild count for the *card* now diverged sharply from the rebuild count for the *score text*, which is what you want to see: it means the tree’s granularity matches the actual data granularity.

Why this matters beyond one app

The pattern generalizes past Kompete. Any Flutter app with concurrent live state — chat, live scores, presence, notifications, telemetry dashboards — tends to accumulate this kind of waste as features get added by different people at different times, because each addition is locally reasonable. Nobody sets out to wrap a whole card in a StreamBuilder; it’s just the fastest way to get a feature working, and it stays that way until someone profiles it. The fix isn’t a rule you can apply once; it’s a habit of periodically re-running the rebuild tracker against real usage and treating anomalies as signal, the same way you’d treat a spike in a backend latency dashboard.

This is the kind of pass we run on client codebases regularly at orithLabs — not a rewrite, just DevTools open against the real app, reading rebuild counts against what the data actually changes, and fixing the specific spots where the tree’s granularity doesn’t match the state’s granularity. It’s slower than skimming for anti-patterns, but it’s the only way we’ve found to know for certain what’s actually costing frame time versus what just looks suspicious.