Taming Flutter Rebuilds in a Data-Heavy Patient Dashboard
The Dashboard That Died by Lunch
At Jio Health I spent years on the doctor app, the Flutter app clinicians use during a shift. The heart of it is one screen: a live dashboard showing the doctor's current patients, streaming vital signs, a couple of trend charts, and a feed of new results, all updating in near real time from the server. A doctor stares at it for hours.
Two complaints kept coming back. Scrolling stuttered and the charts hitched when new data arrived. And the tablets didn't last a shift: a device that was full at 8am needed a charger by lunch. For a clinician moving between rooms, a dead tablet at noon breaks the workflow.
I assumed the streaming data was heavy and we'd need to throttle the server. I was wrong. The server was fine. We were burning the battery ourselves, one rebuild at a time.
Diagnosing With DevTools
The frame budget on a 60Hz screen is 16 milliseconds. Miss it consistently and the user feels jank. I opened the Flutter DevTools performance timeline while a realistic data stream ran, and frames were coming in at 30 to 40 milliseconds: more than double our budget, continuously. That's also why the battery was dying: the CPU never got to rest.
Then I turned on "Track Widget Rebuilds". Every time a single patient's vitals updated, which was several times a second across the panel, practically the entire screen rebuilt. The whole patient list. The charts. The results feed. Widgets that had nothing to do with the patient whose heart rate ticked up by one.
The Root Cause: One God Object, One God Widget
There was one big state object holding everything: the patient list, every patient's latest vitals, the chart series, the results feed. And one big widget near the top of the tree listened to all of it. So the server pushes one patient's new heart rate, the state object updates, the widget is notified, and Flutter rebuilds its entire subtree. Every row, every chart, every list item, for a change that touched one number in one row.
Multiply that by a busy panel updating several times a second and the screen is almost never idle. That's your 40ms frames. That's your battery.
The Fixes, In Order of Payoff
No single change fixed this. It was a stack of them, in rough order of return.
Selective rebuilds were the big one. Widgets subscribed to the whole state object when they only cared about a sliver of it. With the state approach we were using, the fix was to let each widget listen to one derived value: a `Selector` that pulls out only that row's vitals, and a `select` on the provider so a widget rebuilds only when its specific patient's data changes. A heart rate widget for patient A now rebuilds when patient A's heart rate changes, and at no other time.
Splitting the god widget was the prerequisite. You can't rebuild just a piece if the whole thing is one giant `build` method. I broke the dashboard into small, focused widgets (a patient row, a vitals cell, a chart, a feed item) each owning its own subscription, so a change has a smaller blast radius.
`const` constructors everywhere they belonged. Flutter can skip rebuilding a `const` widget entirely, because it's guaranteed not to have changed. A lot of the tree was static chrome (labels, icons, padding, dividers) with no business rebuilding on a vitals tick. Marking them `const` let the framework skip them. This is close to free and I'd left it on the table for years.
`RepaintBoundary` around the charts. The trend charts are expensive to paint. Wrapping each one in a `RepaintBoundary` isolates its painting, so a repaint elsewhere doesn't drag the chart into a repaint too, and vice versa.
Stable keys on the list rows. The patient list was rebuilding and reordering, and without stable keys Flutter couldn't tell that "the row for patient A" was the same element frame to frame, so it did more reconciling than needed. A stable key tied to the patient's ID let Flutter reuse elements instead of rebuilding them, and stopped state from occasionally attaching to the wrong row when the list reordered.
Not rebuilding the whole list for one row. The list level version of selective rebuilds. A single patient's update should mutate one row, not rebuild the entire list view. Once rows subscribed individually, the list stopped amplifying rebuilds.
Death by a Thousand Cuts
The last category didn't show up as an obvious rebuild. It was work happening inside build on every pass.
The dashboard formatted timestamps, sorted the patient list, and computed derived values (like coloring a vital red when it crossed a threshold) right there in the build methods. Sorting inside build means sorting the entire list again every time any row rebuilds. Formatting a timestamp with a heavy formatter allocates a new object on every row on every frame. Each one is cheap. Thousands per second are not.
One example: a tiny helper called inline in build walked a patient's recent readings to compute a trend arrow. Trivial on its own, but called for every visible row on every rebuild it was a measurable chunk of frame time. Moving it out of build, computing it when the data actually changes and caching the result, erased it from the timeline.
The rule I took away: build should assemble widgets, not do work. Sorting, formatting, and deriving all moved out, computed when the underlying data changes rather than when the UI repaints.
The Payoff
I reopened the DevTools timeline with the same data stream. Frame build times dropped under 8 milliseconds, comfortably inside the 16ms budget. The rebuild tracker went from lighting up the whole screen to flickering one small widget at a time, exactly the ones whose data had changed. Jank frames during scrolling went to essentially zero.
And the number that mattered to the doctors: the tablets lasted the shift. With the CPU idle most of the time instead of rebuilding widgets nobody asked for, battery drain came down to what a full charge could carry through the day. The performance fix and the battery fix were the same fix: every unnecessary rebuild is CPU cycles, and CPU cycles are milliamp hours.
Key Takeaways
- Measure before you guess: the DevTools timeline and "Track Widget Rebuilds" pointed straight at the culprit; I'd have wrongly blamed the server otherwise.
- Subscribe to the slice, not the whole: a widget listening to one big state object rebuilds on every unrelated change; make it listen only to the data it shows.
- Small widgets shrink the blast radius: you can't rebuild just a piece of one giant widget, so split it up and let each part own its subscription and its `const` chrome.
- Isolate expensive paints and stabilize lists: `RepaintBoundary` around charts and stable keys on rows stop unrelated repaints and reconciliation from becoming amplifiers.
- Keep `build` dumb: move sorting, formatting, and derived computation out of `build` and cache it; a thousand tiny functions per frame is a real battery drain.