Published on

Flutter Performance Part 2: Putting Your UI on a Diet

Authors
  • avatar
    Name
    Phat Tran
    Twitter

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:

  1. The new child widget is identical to the old one, the very same object in memory. The framework stops right there: no build call, no diffing, the entire subtree is skipped.
  2. The new widget has the same runtimeType and key as the old one (Widget.canUpdate returns true). The element is kept, the child's build runs, and the walk continues one level down.
  3. 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 const call site becomes possible. A method call can never be const. Extraction does not skip the rebuild, but it unlocks the thing that does. This is where the folklore came from: people extracted, added const, saw the improvement, and credited the extraction.
  • Its own BuildContext, which localizes inherited dependencies. Call Theme.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 StatefulWidget and its setState touches only its own subtree. With helper methods, the only setState available 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. The NumberFormat example 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 FadeTransition or AnimatedOpacity over rebuilding an Opacity widget with a new value every frame.
  • Give long lists an itemExtent or prototypeItem. When every row has the same height, the list can lay out and scroll without measuring each child.
  • Be suspicious of shrinkWrap: true inside 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.