ListView.builder: Lazy Loading Lists That Scale in Flutter
Why constructor-built lists blow up memory, how ListView.builder creates children on demand, and how to build paginated infinite-scroll lists with keys and correct item extents.
Published on • October 29, 2026
AI Assistant

Ten thousand rows. You paste a List.generate into a ListView(children: [...]) and it works in the demo — then it janks on a mid-range phone, memory spikes, and scroll-to-top takes a second. The problem isn’t the data; it’s that the plain ListView constructor builds every widget up front. ListView.builder flips that model: it creates children on demand, only as they scroll onto the screen.
In this tutorial, you will learn why constructor-built lists degrade with size, how the builder constructor actually works, how to add pagination and infinite scroll on top of it, and the common mistakes (pre-building widgets, missing keys) that quietly undo its benefits.
Why the default constructor falls apart
The standard ListView constructor takes a fully materialized list of widgets. That means for N items you pay for N widget objects, N elements, and N render objects the moment the list is created — even if the user only ever sees ten of them. Widget creation, layout, and memory all scale with the total item count rather than the visible count.
As Flutter’s own “Work with long lists” cookbook puts it: for lists with a large number of items, use the ListView.builder constructor, which “creates items as they’re scrolled onto the screen.”
The builder contract
The builder constructor’s core signature looks like this:
ListView.builder({
required NullableIndexedWidgetBuilder itemBuilder,
int? itemCount,
double? itemExtent,
ItemExtentBuilder? itemExtentBuilder,
Widget? prototypeItem,
ChildIndexGetter? findChildIndexCallback,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
bool addSemanticIndexes = true,
// ... plus the usual ScrollView parameters
})
The key points from the API docs:
- The constructor “is appropriate for list views with a large (or infinite) number of children because the builder is called only for those children that are actually visible.”
itemBuilderis called only with indices>= 0and< itemCount.- Providing a non-null
itemCountimproves the ability to estimate the maximum scroll extent — which means a more accurate scrollbar and fewer scroll-metric surprises. itemBuildermay legally returnnull; if it does, the scroll view stops calling it even beforeitemCountis reached. Useful for lazily loaded pages, but note thatmaxScrollExtentwon’t be accurate until the user reaches the end, and aScrollbarcan grow as the user scrolls.
The simplest version of the pattern from the cookbook:
final items = List<String>.generate(10000, (i) => 'Item $i');
ListView.builder(
itemCount: items.length,
prototypeItem: ListTile(title: Text(items.first)),
itemBuilder: (context, index) {
return ListTile(title: Text(items[index]));
},
)
prototypeItem deserves attention: it lets the scroll view measure one representative child to learn every child’s extent. The docs recommend specifying either prototypeItem, itemExtent, or itemExtentBuilder whenever possible, because “the scrolling machinery can use the foreknowledge of the children’s extent to save work, for example when the scroll position changes drastically.” Use itemExtent (or prototypeItem) for fixed-size rows, and itemExtentBuilder for variable-size rows.
Never pre-build widgets inside the builder
The API docs are blunt about a common anti-pattern: the itemBuilder “should always create the widget instances when called. Avoid using a builder that returns a previously-constructed widget.” If your children are created in advance — say, in a List.generate stored in a field, then handed to the builder — you’ve simply rebuilt the memory-hungry default constructor with extra steps. Build fresh widgets inside itemBuilder from cheap data, not from cached widgets.
Pagination and infinite scroll
A lazy list plus a network backend naturally produces infinite scroll. The recipe: keep a growing data list, fetch the next page when the last index approaches the end, and let ListView.builder do the rendering.
class PagedListView extends StatefulWidget {
const PagedListView({super.key});
@override
State<PagedListView> createState() => _PagedListViewState();
}
class _PagedListViewState extends State<PagedListView> {
final List<Photo> _photos = [];
bool _loading = false;
bool _hasMore = true;
int _page = 0;
@override
void initState() {
super.initState();
_fetchPage();
}
Future<void> _fetchPage() async {
if (_loading || !_hasMore) return;
setState(() => _loading = true);
final batch = await api.fetchPhotos(page: _page, pageSize: 40);
setState(() {
_page++;
_photos.addAll(batch);
_hasMore = batch.length == 40;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: _photos.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _photos.length) {
_fetchPage(); // fire-and-forget the next page
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final photo = _photos[index];
return ListTile(
key: ValueKey(photo.id),
leading: CircleAvatar(backgroundImage: NetworkImage(photo.thumbnailUrl)),
title: Text(photo.title),
);
},
);
}
}
Two details make this robust:
- The sentinel item:
itemCountgrows by one while more data exists, and the builder returns a loading indicator for that last index — a simple way to trigger the next fetch near the bottom. - Keys:
ValueKey(photo.id)ties each row’s element and state to a data identity rather than to a position. This matters as soon as items are inserted, removed, or reordered — positional identity would let state (checkboxes, expanded panels, animation state) jump to the wrong item. The API docs also point tofindChildIndexCallbackfor the case where the order of children returned by the builder changes over time; without it, “a child widget may not map to its existing RenderObject when the order of children changes… this may result in state-loss.”
Common mistakes checklist
- Building all items first. Mapping your data to widgets in
build()and passing the result defeats lazy loading entirely. - Missing keys on mutable items. Use a stable, unique
Key(the domain ID) whenever rows have state or can reorder. - Omitting
itemCountwhen you know it. An unknown count degrades scroll-extent estimation and scrollbar behavior. - Heavy work in
itemBuilder. It runs during layout for visible rows; move JSON parsing or image decoding off the UI isolate (seecompute/Isolate.run). - Ignoring item extents. Fixed-height rows should pass
itemExtentorprototypeItemfor cheaper, more predictable scrolling. - No repaint boundaries on complex rows.
addRepaintBoundariesdefaults totrue— don’t turn it off for expensive children without measuring.
Practical takeaways
ListView(children: [...])materializes every widget;ListView.buildercreates children only as they scroll into view.- Always pass
itemCountwhen known; it improves scroll-extent estimation. - Give fixed-size lists
itemExtentorprototypeItem, and variable-size listsitemExtentBuilder. - Assign
ValueKeybased on data identity, and usefindChildIndexCallbackwhen builder order changes. - Build widgets fresh inside
itemBuilder; cache data, not widgets.
Conclusion & Next Steps
With the builder constructor, a 10,000-item list costs roughly the same as a 20-item list — only visible children exist. Layer pagination on top and you can present effectively unbounded datasets. Next, look at GridView.builder (same delegate model for grids), SliverList inside CustomScrollView for mixed scrolling layouts, and itemExtentBuilder for variable-height virtualization.
Sources
- Flutter Cookbook — Work with long lists. https://docs.flutter.dev/cookbook/lists/long-lists
- Flutter API — ListView.builder constructor. https://api.flutter.dev/flutter/widgets/ListView/ListView.builder.html