- 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 engine 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.
Here are the habits that keep a UI lean, roughly in the order I reach for them.
1. const is not just a lint suggestion
Your IDE nags you with blue squiggles to add const for a reason. A const widget is a single canonical instance created at compile time. During a rebuild, when Flutter walks the tree and meets the exact same instance it saw last frame, it can skip that widget and its entire subtree without diffing anything.
A banking dashboard is full of static icons, labels, and card backgrounds. Marking those const saves the engine thousands of pointless comparisons on every rebuild, and it costs you six keystrokes. Turn on the prefer_const_constructors lint and let the analyzer find the opportunities for you.
2. Extract widgets, not helper methods
This one is controversial, and I held the opposite opinion for a while. Many of us split a long build method into _buildHeader(), _buildBody(), and friends because it reads nicely.
The problem: a helper method shares its parent's BuildContext. When the parent rebuilds (say the user types into a text field at the bottom of the screen), every helper method runs again and the whole screen rebuilds with it.
A separate widget class draws a real boundary:
// Bad: shares the parent's context. If the parent rebuilds, this rebuilds too.
Widget _buildHeader(String title) {
return Text(title, style: const TextStyle(fontSize: 24));
}
// Good: a standalone class. Combined with 'const' at the call site,
// the engine can skip this subtree entirely when the parent rebuilds.
class HeaderWidget extends StatelessWidget {
final String title;
const HeaderWidget({super.key, required this.title});
Widget build(BuildContext context) {
return Text(title, style: const TextStyle(fontSize: 24));
}
}
3. Isolate repaints with RepaintBoundary
Flutter paints neighboring widgets into shared layers. If a small loading spinner animates continuously, the static balance and transaction list next to it can get repainted 60 times a second for no reason.
Wrapping the spinner in a RepaintBoundary gives it its own layer, so it can animate all day without dragging the rest of the screen into every repaint.
The catch: each boundary allocates a bitmap cache in RAM. Wrap everything in boundaries and you trade a rendering problem for a memory problem, which is a worse deal. Add one where the profiler shows repeated repaints of static content, not by default.
4. 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}');
},
);
If you have adopted Signals, you get fine-grained reactivity by design: only the widgets that actually read a signal are marked for rebuild when its value changes. It does not remove Flutter's build mechanics, but it makes the "rebuild only this one Text" pattern the default instead of something you have to engineer.
5. A few cheap wins
Some smaller habits that add up on list-heavy screens:
- 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.