- Published on
Flutter Performance Part 2: Putting Your UI on a Diet
- Authors

- Name
- Phat Tran
In Part 1 we learned to profile before touching anything. If you ran the CPU profiler on a janky screen, there is a good chance you found the framework rebuilding parts of the UI that had not changed at all.
The math is unforgiving. At 60 FPS you get about 16 milliseconds per frame, and on a modern 120Hz screen that budget shrinks to 8. Every wasted rebuild spends time you do not have.
Before we get to techniques, though, we need one section on how Flutter decides what to rebuild. Half of the rebuild advice circulating in blog posts and conference talks is folklore, and without the mechanism you cannot tell which half. I have repeated some of that folklore myself, and I will correct the worst of it in section 3.
1. What a rebuild actually costs
Your build method returns widgets, and widgets are cheap. They are small immutable objects that describe configuration; the framework is explicitly designed around creating and throwing them away every frame. The expensive structures are the two trees behind them: the element tree, which is mutable and holds state, and the render tree, which does layout and painting.
When a widget rebuilds, the framework walks its children and, for each slot, Element.updateChild picks one of three outcomes:
- The new child widget is identical to the old one, the very same object in memory. The framework stops right there: no
buildcall, no diffing, the entire subtree is skipped. - The new widget has the same
runtimeTypeandkeyas the old one (Widget.canUpdatereturns true). The element is kept, the child'sbuildruns, and the walk continues one level down. - Neither holds. The old subtree is torn down and a new one is inflated. This is the expensive case, and also the rare one.
Here is the part that changes how you think about optimization: when case 2 produces widgets with the same values as last frame, the render objects receive identical properties, nothing calls markNeedsLayout or markNeedsPaint, and no layout or paint happens. An "unnecessary rebuild" costs you the build functions and the tree diff, not a repaint of the screen.
So the goal is not zero rebuilds. The goal is three narrower things: keep build methods cheap, stop huge subtrees from being diffed for no reason, and never let a 60-times-per-second animation churn through code that was not written to run 60 times per second.
You can watch all of this happen instead of guessing. In DevTools, enable Track widget builds in the Performance view and the timeline shows every build call by name. The Flutter plugin for VS Code and IntelliJ also shows per-widget rebuild counts live.
2. const is the real skip button
Now the three outcomes above start paying off. A const widget is a canonical instance: every rebuild evaluates the same expression to the exact same object. That triggers case 1, and the framework skips the whole subtree without diffing anything.
A banking dashboard is full of static icons, labels, and card backgrounds. Marking those const costs six keystrokes each and removes them from every future rebuild. Turn on the prefer_const_constructors lint and let the analyzer find the opportunities for you.
Understand the mechanism and you also see the generalization: any stable instance gets the same treatment. A subtree you build once in initState and store in a field is skipped on every rebuild for the same reason a const widget is. const is just the compiler doing that caching for you when all the inputs are compile-time constants. Keep this in your pocket; it comes back in section 4.
3. Extracting widgets does not make your app faster (I used to claim it does)
An earlier version of this post told you to replace helper methods like _buildHeader() with widget classes for performance, and plenty of talks and articles say the same. Readers rightly pushed back, so let me state the mechanics precisely, because the standard version of this advice is wrong.
The claim goes: a helper method shares the parent's BuildContext, so when the parent rebuilds, the helper runs again; a separate widget class draws a boundary the rebuild cannot cross. The first half is true. The second half is not:
class Header extends StatelessWidget {
const Header({super.key, required this.title});
final String title;
Widget build(BuildContext context) {
return Text(title, style: const TextStyle(fontSize: 24));
}
}
// Call site A: helper method. Runs again on every parent rebuild.
Widget _buildHeader() => Text(title, style: const TextStyle(fontSize: 24));
// Call site B: extracted class, non-const call site.
// Every parent rebuild constructs a NEW Header instance. It is not
// identical to last frame's, canUpdate returns true, and its build()
// runs again. Same work as the helper method, case 2 either way.
Header(title: title)
// Call site C: const call site. Same instance every frame.
// Case 1: the subtree is skipped entirely.
const Header(title: 'Accounts')
Extraction alone moves you from "helper method that reruns" to "widget class whose build reruns". Nothing was saved. Benchmark it if you like; the frame times are the same.
So is extracting widgets pointless? No, and this is where the advice deserves to be rebuilt on honest foundations. A widget class gives you four real things a helper method cannot:
- A
constcall site becomes possible. A method call can never beconst. Extraction does not skip the rebuild, but it unlocks the thing that does. This is where the folklore came from: people extracted, addedconst, saw the improvement, and credited the extraction. - Its own
BuildContext, which localizes inherited dependencies. CallTheme.of(context)inside a helper method and the dependency registers on the parent element, so a theme change rebuilds the parent's entire build method. Inside an extracted widget, the dependency registers on the child, and only the child rebuilds. - State can live closer to the leaves. Turn the extracted widget into a
StatefulWidgetand itssetStatetouches only its own subtree. With helper methods, the onlysetStateavailable is the parent's, which rebuilds everything the parent builds. - It exists as far as tooling is concerned. An extracted widget shows up by name in DevTools rebuild tracking, in the widget inspector, and in stack traces. A helper method is invisible in all three.
The corrected rule: extract widgets for structure, readability, and the reasons above. The performance comes from const, from where your state lives, and from what your build methods do, not from the act of extraction.
4. The child parameter is the framework handing you the answer
Remember from section 2 that any stable instance short-circuits the rebuild. AnimatedBuilder, ListenableBuilder, and ValueListenableBuilder all have a child parameter that exists purely to exploit this, and it is the single most underused performance feature in the framework.
You build the expensive, non-animating subtree once, pass it in as child, and the builder hands it back to you every frame untouched. Identical instance, case 1, skipped:
AnimatedBuilder(
animation: _pulseController,
// Built once, before the animation starts.
child: TransactionCard(transaction: widget.transaction),
builder: (context, child) {
// Runs 60-120 times per second. Only the Transform is rebuilt;
// 'child' is last frame's instance, so the card subtree is skipped.
return Transform.scale(
scale: _pulseAnimation.value,
child: child,
);
},
)
Without the child parameter, that TransactionCard and everything inside it gets rebuilt on every tick of the animation. With it, the animation only ever rebuilds one Transform. The same pattern applies when a ValueListenableBuilder wraps a large static layout to update one number inside it.
5. RepaintBoundary, for paint rather than build
Everything so far was about the build phase. Painting is a separate axis, and it has its own version of the problem: Flutter paints neighboring widgets into shared layers, so a small spinner animating continuously can drag the static balance and transaction list next to it into a repaint 60 times a second.
Wrapping the spinner in a RepaintBoundary gives it its own layer, so it can animate all day without touching the rest of the screen. Before you add one, verify the problem is real: enable Highlight repaints in DevTools (or set debugRepaintRainbowEnabled = true) and every repainting region gets a rotating colored border. Static content cycling through rainbow colors next to an animation is your cue.
Two caveats. First, scrolling lists already do this: ListView and friends wrap each item in a repaint boundary by default (addRepaintBoundaries), which is why you rarely need to add your own inside a list. Second, each boundary allocates a layer that costs memory. Wrap everything in boundaries and you trade a rendering problem for a memory problem, which is a worse deal. Add one where the rainbow shows repeated repaints of static content, not by default.
6. Make state management rebuild less
Most wasted rebuilds are not a framework problem, they are a state design problem. If you use BLoC, do not let every state change rebuild the whole screen. Scope the rebuild with buildWhen, or use BlocSelector to subscribe to a single field:
// Only rebuild this specific card when the balance changes
BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) => previous.balance != current.balance,
builder: (context, state) {
return Text('\$${state.balance}');
},
);
Provider users have the same tool in context.select, and Riverpod in ref.watch(provider.select(...)). The principle is identical everywhere: subscribe to the smallest slice of state the widget actually displays.
If you have adopted Signals, you get this granularity by design: only the widgets that read a signal are marked for rebuild when its value changes. It does not change Flutter's build mechanics, but it makes "rebuild only this one Text" the default instead of something you engineer with selectors.
7. A few cheap wins
Some smaller habits that add up on list-heavy screens:
- Hoist expensive objects out of
build. TheNumberFormatexample from Part 1 is the classic: constructing a formatter inside every list row can eat half your frame budget. Anything with a nontrivial constructor belongs in a field, not in a build method that runs per frame. - For animated fades, prefer
FadeTransitionorAnimatedOpacityover rebuilding anOpacitywidget with a new value every frame. - Give long lists an
itemExtentorprototypeItem. When every row has the same height, the list can lay out and scroll without measuring each child. - Be suspicious of
shrinkWrap: trueinside another scrollable. It forces the inner list to lay out all of its children up front.
Up next: freeing the main thread
A lean UI still freezes if you parse a 5MB API response on the main thread. Part 3 covers the event loop, why async/await does not mean concurrency, and how Isolate.run() keeps the app responsive during heavy work.