Skip to content
Tech Stack15 October 2026 · 15 min read

Find Flutter List Rebuilds That Slow Scrolling

Before blaming the renderer, find out which half of the frame is slow and how often your row widget builds. One scroll in profile mode, counted in DevTools, exported twice.

Find Flutter List Rebuilds That Slow Scrolling

A list that scrolls badly is failing in one of two places, and the two take different fixes. Either your Dart code is building and laying out widgets too often, or the raster thread is taking too long to turn the result into pixels. From the outside both look like the same stutter.

What follows produces two numbers for the first case: how much of a frame the UI thread consumed during one defined scroll, and how many times your row widget was built while that happened. Both come out of DevTools, both can be exported, and both can be compared against the same interaction after you change one thing.

Everything here assumes a physical device, a profile build, and DevTools 2.36.0 or newer for the build counts. The Dart below is a reference implementation checked against the documented APIs it calls. It was not executed against a device, and no frame time or build count in this article is a measurement.

Profile build, real device, frozen data

Three conditions decide whether a trace tells you anything, and two of them are stated bluntly in the documentation.

The DevTools performance page says to "use a profile build of your application to analyze performance", because frame rendering times in debug mode are not indicative of release performance. The build modes page repeats it from the other direction: application performance can be janky in debug mode, and you should measure performance in profile mode on an actual device.

Build modeUsable hereWhat the docs say about it
DebugNoPerformance can be janky, and frame times are not indicative of release behavior
ProfileYesTracing is enabled and DevTools can connect to the process
ReleaseNoDebugging and service extensions are disabled

The device requirement is not a preference either. The build modes page states that profile mode is disabled on the emulator and simulator, because their behavior is not representative of real performance, so an attempt to profile on a simulator stops before it produces a misleading number.

The third condition is measurement discipline rather than documented behavior. Freeze the dataset before you capture anything: the same number of rows, the same content, the same images either all cached or all cold. If the list is fed by a live query, rows arriving mid scroll will move your numbers for reasons unrelated to rebuilds. Point the screen at a local fixture for the duration of the measurement, then put the query back.

Which half of the frame is slow

The frames chart answers this before you change any code. Each bar set in the chart is one Flutter frame, and the bars are color coded to separate work from the UI thread from work from the raster thread.

Those two threads do unrelated jobs. The UI thread executes Dart code in the Dart VM, which covers both your application code and the Flutter framework, and the performance page is explicit that you should not block it. The raster thread executes graphics code from the Flutter Engine, which is where Skia runs and where Impeller also does its work.

Read the split first, because it decides whether this procedure applies at all. A tall UI-thread portion is the case covered here. A tall raster portion points elsewhere, though not as far away as it sounds, since the same page notes that if the raster thread is slow, it is a result of something you have done in the Dart code. Frames marked dark red performed shader compilation, which is a renderer question rather than a rebuild question, and what changed when Impeller became the desktop default is the better starting point for those.

The thresholds are plain. A frame has to render in roughly 16 ms or less for 60 FPS, a frame that takes longer causes jank, and DevTools marks janky frames with a red overlay. Selecting one of those red frames opens the Frame analysis tab, which lists hints about expensive operations detected in that frame.

One practical note about the chart: it updates as your app draws new frames, and the page documents a pause button for exactly that reason. Use it before you start reading, or the bars you were about to inspect scroll out from under the cursor.

Getting a real number for row builds

Turn on widget build counts, repeat the interaction, and DevTools reports how many widgets were built in each frame instead of leaving you to infer it.

The feature is recent enough to be worth checking for. The DevTools 2.36.0 release notes describe it as showing widget build counts, with per frame counts in the Frame Analysis tool and an aggregate summary in a new Rebuild Stats tool, both behind a setting the screenshots label "Track widget build counts". The notes do not say where that setting sits in the interface, so look through the performance view's own settings, and expect it to be absent on an older DevTools.

A second route to the same information predates the counter and is useful when you want widget names rather than totals. Enabling Track Widget Builds under the performance view's enhanced tracing options puts build() method events into the timeline, with the name of the widget shown in the event. The equivalent flags can be set from code: the web performance guide documents setting debugProfileBuildsEnabled, debugProfileBuildsEnabledUserWidgets, debugProfileLayoutsEnabled and debugProfilePaintsEnabled to true in your app's main method, and the first pair is what produces build events. The user widgets variant limits events to widgets defined in your own code, which is usually what you want while a framework widget is not the thing under investigation.

That instrumentation is not free, and the performance page says so: frame times might be negatively affected when these options are enabled. A traced run is comparable to another traced run with the same options, and to nothing else.

The capture itself is short enough to repeat without resenting it:

  1. Run the app with flutter run --profile on a connected physical device, then open DevTools and go to the performance view.
  2. Scroll the list once to warm it, then navigate away and back, so the first frames of the real capture are not also the first frames of the screen.
  3. Enable build counts and write down the state of every other tracing option, because the second capture has to match it.
  4. Perform one defined interaction. A flick of a fixed distance is hard to repeat; dragging from a named row to another named row is not.
  5. Pause the frames chart immediately afterwards.
  6. Select the slowest frame, read its UI and raster portions, then read the build counts for that same frame.
  7. Open Rebuild Stats for the aggregate across the whole capture.
  8. Export the snapshot before you touch any code.

Step 8 is the one that gets skipped. Without it, the answer to "did that help" is recollection.

Three reasons one row builds more than once

A build count higher than the number of rows that changed has three documented causes, and they take different fixes. Work through them in this order, because the first is both the most common and the cheapest to rule out.

The first is notification scope. The performance best practices page states that when setState() is called on a State object, all descendant widgets rebuild, and advises localizing the call to the part of the subtree whose UI actually needs to change. A counter in a header and a list of two thousand rows under the same State means every change to the unread count rebuilds every visible row. In the counts this is unmistakable: rows whose data did not change build anyway, and the count tracks how often that unrelated value moves rather than how far you scrolled.

The second is the list's own lifecycle, which catches people arriving from Android. The ListView documentation describes children being created lazily, then describes what happens on the way out: when a child is scrolled out of view, the associated element subtree, states and render objects are destroyed, and a new child at the same position is lazily recreated. Rows are not recycled with their state intact. If a row does real work in its constructor or in initState, that work repeats every time the row returns to the screen, and the count climbs with scroll distance even when no value in your app changed. The documented mitigations are to keep business logic out of the row subtree and to let any StatefulWidget in there hold instantaneous UI state only, or to preserve specific rows with KeepAlive, which the same page notes only works when addAutomaticKeepAlives and addRepaintBoundaries are false, since those parameters wrap each child subtree in other widgets.

The third is the cost of a single build rather than the number of them. The best practices page puts it plainly: avoid repetitive and costly work in build() methods, since build() can be invoked frequently when ancestor widgets rebuild. Here the count stays low while the individual build events in the timeline are long, which is why reading the count and the frame breakdown together matters more than reading either alone.

What the capture showsLikely causeFirst move
Unchanged rows build when an unrelated value changesNotification scope too wideLocalize the notification to the widget that shows the value
Count climbs with scroll distance, nothing changedRows destroyed and recreated on scrollMove work out of the row's constructor and initState
Low count, long individual build eventsExpensive work inside build()Precompute the value and pass it in

Narrowing what one notification reaches

A two-panel print. On the left, a hanging lamp casts a wide cone of yellow light that reaches every card in a column of twelve. On the right, the same lamp casts a narrow beam that lights one single card in the middle while the rest of the column stays in flat blue shadow

The first cause has a mechanical fix: give the changing value its own listener, so the list stops being a descendant of the thing that rebuilds. The shape that over builds usually looks like this.

// Synthetic demonstration, not production code.
class _FeedPageState extends State<FeedPage> {
  final List<FeedRow> rows = demoRows(2000);
  int unreadCount = 0;

  void onUnreadChanged(int next) {
    setState(() => unreadCount = next);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        UnreadBadge(count: unreadCount),
        Expanded(
          child: ListView.builder(
            itemCount: rows.length,
            itemBuilder: (context, i) => FeedRowTile(row: rows[i]),
          ),
        ),
      ],
    );
  }
}

Nothing is wrong with that code until the unread count starts moving, at which point the badge and the entire visible run of rows rebuild together. Isolating the value changes the structure rather than the symptom.

class _FeedPageState extends State<FeedPage> {
  final List<FeedRow> rows = demoRows(2000);
  final ValueNotifier<int> unread = ValueNotifier<int>(0);

  @override
  void dispose() {
    unread.dispose();
    super.dispose();
  }

  void onUnreadChanged(int next) => unread.value = next;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ValueListenableBuilder<int>(
          valueListenable: unread,
          builder: (context, count, child) => UnreadBadge(count: count),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: rows.length,
            itemBuilder: (context, i) => FeedRowTile(row: rows[i]),
          ),
        ),
      ],
    );
  }
}

ValueListenableBuilder calls its builder with the updated value when the value changes, and the builder receives an optional child that does not depend on that value. The documentation recommends the child argument for this reason, since building a value independent subtree once is more efficient than rebuilding it on every tick.

This is also where const collects more credit than the documentation gives it. The performance best practices page does not list const constructors among its rules. What it does describe is the mechanism underneath: the traversal to rebuild all descendants stops when the same instance of the child widget as the previous frame is re-encountered. A const constructor is one way to hand the framework that same instance, which is why it sometimes appears to solve a scrolling problem, and it does nothing when the widget's inputs genuinely differ between frames.

For the third cause the change is arithmetic rather than structural. Compute the formatted string, the derived total or the parsed date once, when the row model is created, and let the row build read a field. If the value has to be recomputed and is genuinely expensive, it does not belong on the UI thread at all, and choosing between isolates and WorkManager is the next decision.

Leave the list constructors alone while you do this. Which list widget to use, and how itemExtent, shrinkWrap and intrinsic sizing change the layout cost, is a separate investigation covered in Flutter performance for lists, images and animations. Changing two things between captures costs you the ability to attribute the result to either.

Making the second measurement comparable

Change one thing, repeat the identical interaction with identical tracing options, and export both snapshots.

The export is what makes this evidence. The performance page describes the export button as downloading a snapshot of the current data on the performance page, and notes that DevTools only supports importing files that were originally exported from DevTools. Name both files after the change you made rather than the day you made it.

Four things have to match between the two captures, and all four are easy to lose:

  • The dataset. Same rows, same count, same images.
  • The tracing options. Build counts and enhanced tracing both affect frame times, so a traced before and an untraced after is not a comparison.
  • The interaction. Same start row, same end row, same gesture.
  • The device state. A phone that has been rendering for ten minutes is not in the thermal condition of one you just picked up.

If the UI-thread portion of the slow frames drops and the row widget's build count for the same interaction drops with it, the change did what you expected. If the count drops and the frame times stay where they were, you removed rebuilds that were never the expensive part. That is still worth knowing, and it is cheaper to learn from an export than from a release.

What the counts cannot tell you

A build count is not a frame time, and treating one as the other is the fastest way to spend a day on nothing. Twenty cheap builds can be irrelevant while two expensive ones are the entire problem, which is why step 6 reads both numbers from the same frame.

Three further limits are worth stating before any of this leaves your machine. Instrumented runs carry the overhead the performance page warns about, so absolute milliseconds under tracing are for comparing against another traced run, not for predicting release behavior. Nothing in this procedure examines the raster side, so a list that stutters because of clipping, opacity or shader work will show a healthy build count and still stutter. And none of it inspects the data layer: a correct row count and a query that reads far more documents than it displays are perfectly compatible, which is its own cost and comes up in how a Flutter and Firebase SaaS app fits together.

The performance view also has a platform boundary. It works for Flutter mobile and desktop apps, and for web apps Flutter adds timeline events to the performance panel of Chrome DevTools instead, so a web target changes the tooling here even though the reasoning survives.

If the list lives in a FlutterFlow project

The measurement is the same, the access is not. FlutterFlow's Local Run documentation states that testing on mobile devices requires downloading code, for which you must be on paid plans, and that Local Run works through the desktop app against a connected device, emulator or simulator once the setup wizard has installed the Flutter SDK and run Flutter Doctor.

That page does not say which Flutter build mode Local Run uses, and one documented detail argues against assuming it is the one you need: Flutter's hot reload documentation states that only apps in debug mode can be hot reloaded or hot restarted, and Local Run offers both. My recommendation is to take the explicit path instead of inferring. Download the code, run flutter run --profile on a physical device yourself, and attach DevTools to that process. The frames and counts you read then are the ones the documentation says are meaningful.

The fix follows the same division. Where the state lives is a project decision you make in the builder, and whether an expensive computation runs per row inside a built in action or in custom Dart changes what the build events show. The sequence does not change: measure in profile mode, read the split, count the builds, change one thing, measure again.

Two numbers to write down

Open the list that bothers you, switch to a profile build on a device, and capture one scroll before forming an opinion. Record which half of the slowest frame was tall, and how many times your row widget built during that scroll. If the raster half was the tall one, nothing above applies and the renderer is where to look next. If the UI half was tall and the count exceeded the number of rows whose data actually changed, you already know what the next change is.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do you count widget rebuilds in a Flutter list?

Enable the widget build counts setting in DevTools and run the app as a profile build, then read the per frame counts in the Frame Analysis tool. The DevTools 2.36.0 release notes describe the feature as showing widget build counts for each Flutter frame, with an aggregate summary in a Rebuild Stats tool. The notes do not say where the setting sits in the interface, so look through the performance view's own settings. An older DevTools will not have it at all, in which case enable Track Widget Builds under enhanced tracing, which puts build() events into the timeline with the widget name shown in the event.

Why does a Flutter list stutter only while scrolling?

Because rows that leave the viewport are thrown away and built again when they return. The ListView documentation states that when a child is scrolled out of view, the associated element subtree, states and render objects are destroyed, and that a new child at the same position is lazily recreated. Rows are not recycled with their state intact. Any work a row does in its constructor or in initState therefore repeats on every pass, which is why a build count can climb with scroll distance even when no value in your data changed.

What is the difference between the UI thread and the raster thread?

The UI thread runs your Dart code and the raster thread turns the result into pixels. Flutter's performance view documentation describes the UI thread as executing Dart code in the Dart VM, covering both application code and the framework, and the raster thread as executing graphics code from the Flutter Engine, where Skia runs and where Impeller also does its work. The frames chart colors each frame by those two portions, so a tall UI portion points at build and layout work. The same page notes that a slow raster thread is still a result of something done in Dart code.

Can you profile a Flutter app on an emulator or simulator?

No, because profile mode is disabled there. The build modes documentation states that profile mode is disabled on the emulator and simulator, since their behavior is not representative of real performance. Debug mode does run on both, but the same page warns that application performance can be janky in debug mode and that performance should be measured in profile mode on an actual device. The DevTools performance page repeats the point from its side, noting that frame rendering times in debug mode are not indicative of release performance. A physical device is a prerequisite here rather than a preference.

Does adding const to list rows fix scrolling jank?

Sometimes, and not for the reason it is usually given. The performance best practices page does not list const constructors among its rules. What it does describe is the mechanism underneath: the traversal to rebuild all descendants stops when the same instance of the child widget as the previous frame is re-encountered. A const constructor is one way to supply that same instance, so it helps where a subtree is genuinely unchanged between frames and does nothing where the widget's inputs differ. Count the builds before and after the change instead of assuming it worked.