Week 18: Performance: ListView Virtualization, Rebuild Profiling & DevTools

A safety net of tests doesn't make an app feel fast — it just keeps it from silently breaking. This week closes out the testing-and-performance phase by profiling and fixing the two most common sources of a sluggish Flutter app: unvirtualized lists and unnecessary rebuilds, using Flutter DevTools directly.

Phase 7 of 8 Week 18 of 20 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why ListView.builder scales to long lists where a plain ListView does not
  • Profile unnecessary widget rebuilds with Flutter DevTools
  • Apply const constructors and targeted rebuilds to reduce wasted work
  • Use the DevTools Performance view to find a real, specific bottleneck

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.

before — every item built immediately
ListView(
  children: products.map((p) => ProductTile(product: p)).toList(), // all 500, at once
)
after — built lazily, on demand
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.

terminal — opening DevTools against a running app
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

before — everything rebuilds on every keystroke
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.

an even more targeted fix — isolate just the changing part
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/paint dominated 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

Hands-on

Profile and fix a slow screen

Take the longest list screen built so far and measurably improve it with DevTools evidence.

Requirements:

  1. Seed a list screen from an earlier week with at least 300 items, and confirm it currently uses ListView rather than ListView.builder (convert it if it doesn't already use the plain form).
  2. Record a DevTools Performance timeline scrolling the un-virtualized list; note frame build times.
  3. Convert it to ListView.builder (with itemExtent if rows are a fixed height) and re-record the same scroll interaction, comparing frame times.
  4. Find one widget in the app that rebuilds more often than it should (using "Track Widget Rebuilds"), and fix it with either a const constructor or by isolating the changing state into its own smaller widget.
  5. Report before/after frame timings for both fixes, screenshotted or transcribed from DevTools.
Hint

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?

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?

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?

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?

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.