1. ListView.builder Virtualization
A plain ListView(children: [...]) builds every single child widget
immediately, whether visible or not — fine for a handful of items, genuinely
wasteful for hundreds. ListView.builder builds children lazily, only as
they scroll into view.
ListView(
children: products.map((p) => ProductTile(product: p)).toList(), // all 500, at once
)
ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductTile(product: products[index]),
)
For a fixed-height row, adding itemExtent lets Flutter skip measuring
each item's size individually, which is the same class of optimization
getItemLayout provides for a React Native FlatList.
2. Profiling Rebuilds with DevTools
Flutter DevTools' Performance view (or the "Track Widget Rebuilds" overlay) shows exactly which widgets rebuild on every frame — the direct way to find a widget rebuilding far more often than its visible output justifies.
flutter run
# then follow the DevTools URL printed in the terminal
A common finding: a large parent widget rebuilds on every keystroke of a text field
nested somewhere deep inside it, dragging every sibling widget along for the ride —
even ones with nothing to do with the text field at all — simply because they all
live under the same setState call's scope.
3. const Constructors & Targeted Rebuilds
class SearchScreen extends StatefulWidget { /* ... */ }
class _SearchScreenState extends State<SearchScreen> {
String _query = '';
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(onChanged: (v) => setState(() => _query = v)),
const ExpensiveProductGrid(), // rebuilds too, even though _query doesn't affect it
],
);
}
}
Marking ExpensiveProductGrid() as const (as above) is
already a real fix — Flutter recognizes an identical const widget
instance across rebuilds and skips rebuilding it entirely, rather than rebuilding
every child unconditionally just because its parent's build ran again.
Column(
children: [
_SearchField(onChanged: (v) => ref.read(searchQueryProvider.notifier).state = v),
const ExpensiveProductGrid(), // never rebuilds from typing at all, by construction
],
)
Moving the changing state into its own small widget (or a Riverpod provider read
only where needed) is the more thorough fix — it stops the unrelated rebuild from
ever being triggered in the first place, rather than relying on const
to cheaply skip it after the fact.
4. Finding a Real Bottleneck
DevTools' Performance view records frame timings directly — a frame consistently taking longer than about 16ms (for 60fps) or 8ms (for 120fps) is a dropped frame, a real, measurable jank a user actually perceives.
- Record a timeline while performing the actual slow interaction (scrolling a long list, typing in a laggy search field).
- Look for frames exceeding the budget, and expand them to see which widget's
build/paintdominated that frame's time. - Fix the specific widget the timeline points to — not a widget that merely looks inefficient by inspection.
- Re-record the same interaction and confirm frame times actually improved, the same profile-first discipline this site's Go and React Native courses both teach.
5. Hands-on Exercise
Profile and fix a slow screen
Take the longest list screen built so far and measurably improve it with DevTools evidence.
Requirements:
- Seed a list screen from an earlier week with at least 300 items, and confirm it currently uses
ListViewrather thanListView.builder(convert it if it doesn't already use the plain form). - Record a DevTools Performance timeline scrolling the un-virtualized list; note frame build times.
- Convert it to
ListView.builder(withitemExtentif rows are a fixed height) and re-record the same scroll interaction, comparing frame times. - Find one widget in the app that rebuilds more often than it should (using "Track Widget Rebuilds"), and fix it with either a
constconstructor or by isolating the changing state into its own smaller widget. - Report before/after frame timings for both fixes, screenshotted or transcribed from DevTools.
If converting to ListView.builder doesn't seem to improve scroll performance at all, check whether each item's itemBuilder is itself doing expensive work on every build (an unmemoized image decode, a heavy computation) — virtualization only saves the cost of building off-screen items; a genuinely slow per-item build is a separate problem virtualization alone doesn't fix.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does a plain ListView(children: [...]) become a real performance problem at a few hundred items, where ListView.builder does not?
Why does a plain ListView(children: [...]) become a real performance problem at a few hundred items, where ListView.builder does not?
The plain form builds every single child widget immediately when the list itself builds, regardless of whether it's ever scrolled into view — for hundreds of items, that's hundreds of widgets built (and kept in memory) for no visible benefit. ListView.builder only builds items as they're about to become visible, which keeps the cost proportional to what's actually on screen rather than the full list length.
Q2
Why does marking a widget as const allow Flutter to skip rebuilding it, even when its parent rebuilds?
Why does marking a widget as const allow Flutter to skip rebuilding it, even when its parent rebuilds?
A const widget is a compile-time constant — Flutter can recognize that the exact same widget instance is being used again across rebuilds (since a const value is identical by definition) and skip rebuilding it entirely, rather than treating it as a new instance to reconcile every time its parent's build method runs.
Q3
Why is moving changing state into its own smaller widget considered a more thorough fix than relying on const alone?
Why is moving changing state into its own smaller widget considered a more thorough fix than relying on const alone?
const lets Flutter skip rebuilding an unaffected widget cheaply, but the parent's build method (and everything inside it that isn't marked const) still runs on every state change. Isolating the changing state into its own widget means the unrelated parts of the tree are never even considered for rebuild in the first place, which is a stronger guarantee than relying on Flutter's const-widget shortcut after the fact.
Q4
Why does the exercise ask for before/after frame timings rather than just applying the fixes and trusting they helped?
Why does the exercise ask for before/after frame timings rather than just applying the fixes and trusting they helped?
A fix that seems reasonable in theory doesn't guarantee a measurable improvement in practice — the same profile-first, re-profile-after discipline this week's section teaches directly. Without before/after numbers, there's no actual evidence the change helped at all, only an assumption; measuring confirms the fix targeted the real bottleneck rather than something that merely looked like one.