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 mode | Usable here | What the docs say about it |
|---|---|---|
| Debug | No | Performance can be janky, and frame times are not indicative of release behavior |
| Profile | Yes | Tracing is enabled and DevTools can connect to the process |
| Release | No | Debugging 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:
- Run the app with
flutter run --profileon a connected physical device, then open DevTools and go to the performance view. - 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.
- Enable build counts and write down the state of every other tracing option, because the second capture has to match it.
- 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.
- Pause the frames chart immediately afterwards.
- Select the slowest frame, read its UI and raster portions, then read the build counts for that same frame.
- Open Rebuild Stats for the aggregate across the whole capture.
- 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 shows | Likely cause | First move |
|---|---|---|
| Unchanged rows build when an unrelated value changes | Notification scope too wide | Localize the notification to the widget that shows the value |
| Count climbs with scroll distance, nothing changed | Rows destroyed and recreated on scroll | Move work out of the row's constructor and initState |
| Low count, long individual build events | Expensive work inside build() | Precompute the value and pass it in |
Narrowing what one notification reaches

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.
