Skip to content
Blog

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.”
  • itemBuilder is called only with indices >= 0 and < itemCount.
  • Providing a non-null itemCount improves the ability to estimate the maximum scroll extent — which means a more accurate scrollbar and fewer scroll-metric surprises.
  • itemBuilder may legally return null; if it does, the scroll view stops calling it even before itemCount is reached. Useful for lazily loaded pages, but note that maxScrollExtent won’t be accurate until the user reaches the end, and a Scrollbar can 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: itemCount grows 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 to findChildIndexCallback for 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 itemCount when 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 (see compute/Isolate.run).
  • Ignoring item extents. Fixed-height rows should pass itemExtent or prototypeItem for cheaper, more predictable scrolling.
  • No repaint boundaries on complex rows. addRepaintBoundaries defaults to true — don’t turn it off for expensive children without measuring.

Practical takeaways

  • ListView(children: [...]) materializes every widget; ListView.builder creates children only as they scroll into view.
  • Always pass itemCount when known; it improves scroll-extent estimation.
  • Give fixed-size lists itemExtent or prototypeItem, and variable-size lists itemExtentBuilder.
  • Assign ValueKey based on data identity, and use findChildIndexCallback when 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