Flutter ListView Jank: The 5 Fixes I Run Before Touching Anything Else
A tester on a three-year-old budget Android filed a bug last week: our chat screen scrolled "like a slideshow". On my Pixel it was fine, so I nearly closed it as can't-reproduce. Then I ran the app on a cheap device myself and watched the frame chart turn into a bar code. The list was the problem, and it was every problem at once.
Fixing scroll jank in Flutter lists is less about clever tricks and more about removing work you never meant to do. Here are the five fixes I now run through before touching anything exotic. All of them apply to GridView and custom scrolls too.
1. Stop building the whole list
The default ListView constructor builds every child immediately, including the 990 rows you cannot see yet. Ten items, no issue. Five hundred, and your first frame is gone.
// Builds all 500 widgets up front
ListView(
children: messages.map((m) => MessageTile(message: m)).toList(),
)
// Builds a tile only when it scrolls into view
ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) => MessageTile(message: messages[index]),
)
This one swap took our first-build time from roughly 400ms to under 40. If you remember nothing else from this post, remember this.
2. Give the layout pass a break with itemExtent
If your rows are all the same height, say it out loud:
ListView.builder(
itemCount: messages.length,
itemExtent: 64.0,
itemBuilder: (context, index) => MessageTile(message: messages[index]),
)
With itemExtent set, Flutter skips per-child layout negotiation and can compute scroll positions by simple multiplication. It reads like a micro-optimization. On a long list it is not micro.
3. The shrinkWrap trap
I see this one in every codebase I inherit:
Column(
children: [
Header(),
ListView(
shrinkWrap: true,
children: items.map(buildTile).toList(),
),
],
)
shrinkWrap: true tells the list to measure all of its children so it can hug them. Which means it just built and laid out every row, exactly like the lazy constructor you avoided. You paid for laziness and got none of it.
If the list should fill the remaining space, wrap it in Expanded. If it genuinely needs to be a small scrolling section inside other content, that is what slivers are for. Either way, shrinkWrap on a big list is a bug wearing a helpful expression.
4. Do less work while scrolling
The builder runs on every frame that brings items in. Whatever you do inside it, you do during a scroll. A few habits that matter:
- Decode images before they hit the list, and give
Imagewidgets explicit sizes so the layout does not guess. - Make item widgets
const-friendly. Constant tiles get skipped on rebuild instead of rebuilt. - Do not
watchan entire app-wide provider from inside a tile. One changed field then rebuilds 50 visible rows. I learned this the hard way back when I rewrote our state management three times.
One nuance people get wrong: ListView.builder already wraps each item in a repaint boundary by default (addRepaintBoundaries is true). Sprinkling more of them rarely helps. What does help is making sure an animated or filtered widget inside one tile does not repaint that whole tile every tick.
5. Measure before and after
Guessing at jank is a waste of an afternoon. Open DevTools, go to the Performance view, record a scroll, and look at the frame chart. Red bars are dropped frames; expand one and the timeline tells you whether the time went to build, layout, or paint. There is also a widget rebuild tracker that will show you, bluntly, that your tiles rebuilt 40 times during one scroll.
Tune cacheExtent only after you have measured. The default (250 logical pixels beyond the viewport) is fine for most lists. Cranking it up hides build cost during fast flings, but building further ahead is not free either.
My whole routine now fits in four lines, and I keep it pinned as a snippet in Snippet Ark: builder constructor, itemExtent where heights match, no shrinkWrap on long lists, DevTools before and after. The budget Android scrolls at 60fps now. The tester closed the bug with the comment "magic". It was not magic. It was deleting work the list never needed to do in the first place.