Scrolling through thousands of items without lag is what separates a polished app from one that feels unresponsive. In this post on lists and slivers, I'll walk you through building smooth, performant scrollable content in Flutter—from simple lazy-loaded lists to complex layouts with sticky headers and multiple scroll zones.
ListView.builder — Builds items lazily as they appear
The most common pitfall I see is developers using ListView(children: [...]) with a fixed list of widgets. This renders everything upfront, even items far below the fold. ListView.builder solves this by building items only when they're about to appear on screen.
I use this in every production app where content is dynamic. The constructor takes an itemCount and an itemBuilder callback that receives the index and returns a widget. Flutter recycles the space, so memory usage stays constant regardless of list size.
Here's a practical example—imagine fetching user profiles from an API:
class UserListScreen extends StatefulWidget {
@override
_UserListScreenState createState() => _UserListScreenState();
}
class _UserListScreenState extends State {
List users = [];
bool isLoading = false;
@override
void initState() {
super.initState();
_fetchUsers();
}
Future _fetchUsers() async {
setState(() => isLoading = true);
final response = await http.get(Uri.parse('/api/users'));
final data = jsonDecode(response.body) as List;
setState(() {
users = data.map((json) => User.fromJson(json)).toList();
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (isLoading) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
leading: CircleAvatar(
backgroundImage: NetworkImage(user.avatar),
),
);
},
);
}
}The key insight: only the visible viewport plus a small buffer are kept in memory. When a user scrolls past an item, that widget is destroyed and replaced. This pattern scales to millions of items without crashing.
Separated lists — ListView.separated for dividers
Once your list grows, you'll want visual separation between items. ListView.separated lets you define a separatorBuilder callback alongside the item builder. This is cleaner than manually adding dividers inside itemBuilder.
I reach for this whenever UX designers ask for custom dividers—whether it's a thin line, padding, or a card-style gap. The separator is built at index positions between items, so the count is naturally itemCount - 1.
Here's a real example from a ride-hailing app where rides need visual breathing room:
ListView.separated(
itemCount: rides.length,
itemBuilder: (context, index) {
final ride = rides[index];
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
elevation: 2,
child: ListTile(
title: Text('${ride.pickupLocation} → ${ride.dropLocation}'),
subtitle: Text(ride.scheduledTime),
trailing: Text('\$${ride.fare}'),
),
),
);
},
separatorBuilder: (context, index) {
return SizedBox(height: 8);
},
)You could also make separators context-aware. For instance, style the divider differently on the last item or alternate colors:
separatorBuilder: (context, index) {
final isLast = index == rides.length - 2;
return Divider(
color: isLast ? Colors.grey[300] : Colors.grey[200],
thickness: isLast ? 2 : 1,
);
}This pattern keeps your build logic organized and prevents the common mistake of padding or divider logic leaking into the main item builder.
Slivers — CustomScrollView with SliverAppBar and SliverList for complex scrolling
When a single list isn't enough—when you need a sticky header, multiple scroll zones, or a collapsing app bar—slivers are your answer. Slivers are scrollable components that work together within a CustomScrollView. I use them heavily in e-commerce and SaaS dashboards where the header collapses as you scroll.
SliverAppBar is the most common. It can expand and collapse, pin itself to the top, or snap into place. SliverList is a sliver version of ListView, and SliverGrid handles grids.
Here's a travel app example with a collapsing destination image and pinned filter bar:
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text('Destinations'),
background: Image.network(
'https://example.com/header.jpg',
fit: BoxFit.cover,
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: _FilterBarDelegate(),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final destination = destinations[index];
return Padding(
padding: EdgeInsets.all(16),
child: Card(
child: Column(
children: [
Image.network(destination.imageUrl),
ListTile(
title: Text(destination.name),
subtitle: Text('\$${destination.price}'),
),
],
),
),
);
},
childCount: destinations.length,
),
),
],
)The magic is pinned: true on SliverAppBar. As the user scrolls, the header shrinks to a fixed height and sticks to the top. Without slivers, achieving this smoothly requires manual scroll listeners and expensive layout recalculations.
For more control, SliverPersistentHeader lets you pin arbitrary widgets—like filter buttons. The _FilterBarDelegate class manages the height and build logic:
class _FilterBarDelegate extends SliverPersistentHeaderDelegate {
@override
double get maxExtent => 60;
@override
double get minExtent => 60;
@override
Widget build(context, offset, overlapsContent) {
return Container(
color: Colors.white,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_FilterChip('Budget'),
_FilterChip('Rating'),
_FilterChip('Distance'),
],
),
);
}
@override
bool shouldRebuild(_FilterBarDelegate old) => false;
}Slivers compose seamlessly. You can mix SliverAppBar, SliverList, SliverGrid, and custom headers in one scroll view. In my trading app, I've used slivers to layer a sticky trade summary over a scrollable order history without rebuilding the entire tree on each scroll event.
Item extent — itemExtent or prototypeItem speeds up scrolling for fixed-height rows
Here's a performance trick many developers miss. If all your list items have the same height—like a chat app where messages are consistently 80 logical pixels—tell Flutter that upfront with itemExtent. This avoids layout calculations and makes scrolling buttery smooth.
I've seen apps with 10,000+ items drop from 60 fps to 30 fps just by ignoring this. Once I added itemExtent: 80, we hit 60 fps consistently.
Here's a chat screen example:
ListView.builder(
itemCount: messages.length,
itemExtent: 80, // Each message is exactly 80 logical pixels tall
itemBuilder: (context, index) {
final msg = messages[index];
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundImage: NetworkImage(msg.senderAvatar),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(msg.senderName,
style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text(msg.text),
SizedBox(height: 4),
Text(msg.timestamp,
style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
],
),
);
},
)If heights are truly dynamic, use prototypeItem instead. Pass a representative item widget, and Flutter measures it once to estimate the scroll extent:
ListView.builder(
itemCount: posts.length,
prototypeItem: _PostWidget(posts.first),
itemBuilder: (context, index) => _PostWidget(posts[index]),
)This is safer than hardcoding heights but still gives you most of the performance gain. It's perfect for feeds where items vary slightly due to text length but follow a predictable pattern.
Common mistakes
- Using ListView with a fixed children list for dynamic content. Always use
ListView.builderorListView.separatedwhen data comes from an API or state. Fixed lists kill performance and memory usage at scale. - Rebuilding the entire list on state change. If you're calling
setState(() => items = newItems)every time you update, you're needlessly rebuilding widgets. Use providers or state management libraries to granularly update only what changed. - Ignoring itemExtent when all items have the same height. This is free performance. Don't leave it on the table. Measure once and pass the value.
- Overcomplicating slivers. Slivers are powerful but can feel magical if you're new. Start with
SliverAppBarandSliverList, then add complexity only when needed. - Forgetting to handle empty states. An empty list should show a message or illustration, not a blank screen. Wrap your list view in a check for
items.isEmpty. - Loading all data upfront instead of paginating. If your API returns 10,000 records, don't fetch them all at once. Implement pagination or infinite scroll with
ScrollControllerto load more as the user approaches the end.
Key takeaways
ListView.builderis the foundation—use it whenever your list is dynamic or large. Items are built lazily and recycled, keeping memory constant.ListView.separatedreplaces manual divider logic. The separator builder is cleaner and keeps your item builder focused.- Slivers power complex scrolling layouts.
CustomScrollViewwithSliverAppBar,SliverList, andSliverPersistentHeaderhandles sticky headers, collapsing app bars, and multi-zone scrolling without expensive scroll listeners. itemExtentandprototypeItemunlock massive performance gains. If items have fixed or predictable heights, always specify them. This saves layout calculations on every frame.- Combine these tools based on your use case. A simple feed might just need
ListView.builderwithitemExtent. A complex dashboard might layer slivers with pinned headers and dynamic content below. - Profile your scrolling with DevTools. If frame rates drop below 60 fps, check whether you're rebuilding unnecessarily, missing itemExtent, or keeping too much in memory.






Comments
Loading comments…