Your Flutter List Is Fine Until It Isn't: When to Switch to ListView.builder
Column in SingleChildScrollView builds every child up front; ListView.builder builds only what's visible. Here's how the lazy model works, a chat-feed example, and the state and nesting trade-offs to plan for.
18 Feb 2026, 04:26 UTC

The settings page scrolls smoothly. The demo feed with twelve items scrolls smoothly. Then someone loads a real account with 4,000 messages and the app hitches, drops frames, and eats memory like it's paid to. The widgets didn't change — the rendering strategy did, and this is one of the most common performance traps in Flutter.
The takeaway: Column inside SingleChildScrollView builds every child eagerly, while ListView.builder builds only what's near the viewport. For short, static content the first is fine. For anything dynamic or unbounded, the second isn't an optimization — it's the correct default.
What eager rendering actually costs you
When you write this:
SingleChildScrollView(
child: Column(
children: messages.map((m) => MessageTile(m)).toList(),
),
)Flutter constructs every MessageTile up front — all 4,000 of them — before the first frame appears. Each one gets laid out and painted into the element tree even though the user can only see maybe ten. The costs stack up fast:
- Slow first frame: build and layout time scales linearly with item count, so the screen takes noticeably longer to appear.
- Memory growth: thousands of widgets, elements, and render objects stay alive for the entire lifetime of the screen.
- Jank during scroll: a huge render tree makes every scroll frame more expensive to composite.
None of this is visible with twenty items, which is exactly why the pattern survives code review and explodes in production.
How ListView.builder stays cheap
ListView.builder flips the model. Instead of handing Flutter a finished list of children, you hand it a callback and a count:
ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) {
return MessageTile(messages[index]);
},
)Flutter calls itemBuilder only for indices near the visible viewport — typically the visible rows plus a small cache margin. As the user scrolls, rows that leave the screen are disposed and their render objects recycled for incoming rows. Memory stays roughly constant whether the list has 100 items or 100,000.
You can verify this in thirty seconds: put a print(index) inside itemBuilder and run the screen. You'll see a handful of indices logged at startup, not thousands. Do the same with the Column version (a print inside the tile's constructor) and you'll see every item build immediately. For harder numbers, Flutter DevTools' Performance and Memory views will show the difference in first-frame time and retained memory between the two approaches.
A worked example: the chat feed
Say you have a message model and a tile widget:
class Message {
final String author;
final String text;
const Message(this.author, this.text);
}
class MessageFeed extends StatelessWidget {
final List<Message> messages;
const MessageFeed({super.key, required this.messages});
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: messages.length,
itemBuilder: (context, i) => ListTile(
title: Text(messages[i].author),
subtitle: Text(messages[i].text),
),
separatorBuilder: (context, i) => const Divider(height: 1),
);
}
}Two things worth noting here. First, ListView.separated is a variant of builder that inserts dividers via a separate callback — cleaner than conditionally appending Divider widgets inside your item builder. Second, nothing in this code changes when the list grows from 50 messages to 50,000; that's the point.
If your rows all have the same fixed height, add itemExtent: 72.0 (or use prototypeItem with one sample row). This lets the list compute scroll offsets without measuring every child, which makes scrollbar behavior and jump-to-position cheaper and more accurate.
The trade-offs nobody mentions in the demo
Lazy rendering has real costs you should design around:
- Off-screen state is destroyed. If a row contains a
TextFieldand the user scrolls it off screen, the widget is disposed and its local state goes with it. The fix is to hoist state out of the row — into a model, bloc, or controller keyed by item ID — rather than reaching for keep-alive wrappers, which defeat the memory savings. itemCountmust be truthful. If it doesn't match your data length you'll get blank space at the end of the list or aRangeErrorwhen Flutter asks for an index that doesn't exist. If the list is paginated, update the count as data arrives.- Nesting scrollables is a trap. Putting a
ListViewinside another scroll view withshrinkWrap: trueforces the inner list to size itself to its full content — which means building everything, which means you've reinvented the eagerColumnwith extra steps. If you need a header that scrolls away with the list, use aCustomScrollViewwith slivers (SliverToBoxAdapterfor the header,SliverListfor the items) instead.
A decision rule you can apply in review
Keep it simple: if the content is short, static, and heterogeneous — a settings page, a profile card, a form — Column in SingleChildScrollView is perfectly fine and often more readable. The moment content is data-driven, user-generated, paginated, or simply unbounded, reach for ListView.builder (or slivers for composed scrolling). The API has been stable for years, so this isn't a bet on anything new — it's just matching the widget to the data shape.
If you're unsure which side of the line an existing screen sits on, run the print-statement check above against production-sized data. The console output will make the decision for you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.