Caching, Pagination, Lazy Loading & List Performance
Building Lists That Scale to Tens of Thousands of Items
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Production list performance requires: ListView.builder for lazy rendering, cursor-based pagination for stable infinite scroll, multi-level caching (memory to disk to network), correct image memory management, and strategic use of RepaintBoundary and const constructors. These details separate a smooth 60fps list from one that janks on every scroll.
Real-world relevance
In a SaaS collaboration app's activity feed with thousands of messages: ListView.builder lazily renders only visible messages. cached_network_image with memCacheWidth caches avatars at display resolution. Cursor pagination loads 30 messages per page, stable even as new messages arrive. RepaintBoundary wraps each message bubble so typing animations do not repaint the whole list. The result: consistent 60fps scroll on a 5-year-old mid-range phone.
Key points
- Caching Strategies — Three Levels — Memory cache: Map or LRU cache in the repository (fastest, lost on app kill). Disk cache: Hive, SQLite, or HTTP cache (persists across sessions). HTTP cache: ETag/Last-Modified headers with dio_cache_interceptor (server validates freshness). Stack them: memory first, then disk, then network. Return memory-cached data immediately while revalidating from network in background — stale-while-revalidate.
- Stale-While-Revalidate — Show stale cached data immediately, then silently fetch fresh data and update the UI. This gives instant perceived performance. Implementation: emit cached data first, then await network response, then emit fresh data. Riverpod's FutureProvider with keepAlive implements this naturally. Users see content instantly instead of a spinner.
- ListView.builder vs ListView — ListView renders all children at once — OK for fewer than 20 items. ListView.builder creates children lazily on demand — only visible items exist in memory. For 100+ items, ListView is a performance disaster. Always use ListView.builder with itemCount and itemBuilder. The builder is called only for items in or near the viewport.
- Infinite Scroll Implementation — Track: current cursor, isLoading, hasMore. In the ScrollController listener: if scroll position is greater than 80% of max extent and not loading and hasMore, load next page. Append new items to the list. Show a loading indicator at the bottom while fetching. Debounce the scroll listener to avoid double-fetches.
- Cursor vs Offset Pagination — Offset: GET /items?page=2&limit=20. Simple but unstable — if items are added or deleted during pagination, items are skipped or duplicated. Cursor: GET /items?after=cursor123&limit=20. Server returns next cursor. Stable during live data changes. Use cursor for real-time feeds (chat, activity), offset for static or slowly-changing data.
- Image Caching with cached_network_image — cached_network_image downloads images once and caches them to disk. Use CachedNetworkImage with placeholder and errorWidget. For lists with many images: set memCacheWidth and memCacheHeight to decode at display size, not full resolution. A 4K image decoded at 4K resolution for a 100x100 avatar wastes 48MB of memory.
- Image Memory Management — Each decoded image in Flutter occupies GPU memory proportional to its dimensions (width x height x 4 bytes). A full-screen 3000x4000 image uses 45.7MB. In a list of 50 such images, that is 2.3GB — a crash. Solutions: CachedNetworkImage memCacheWidth/memCacheHeight, ResizeImage for in-memory downsampling, evictFromCache() for manual cache management.
- RepaintBoundary for List Items — Wrap complex list items in RepaintBoundary. This creates a separate rendering layer — when one item animates or changes, only that item's layer is repainted, not the entire list. In a chat app with animated delivered indicators, RepaintBoundary prevents the entire message list from repainting on each animation frame.
- const Constructors in Lists — List items that do not change should use const constructors. Flutter's element diffing skips rebuilding const widgets. In a long list with a mix of static content like profile picture and username, and dynamic content like timestamp, const constructors on the static parts reduce rebuild cost significantly.
- SliverList and CustomScrollView — For complex scrollable layouts (collapsible app bar plus tabs plus list), use CustomScrollView with Slivers. SliverList provides equivalent lazy loading to ListView.builder, along with SliverGrid and SliverAppBar. Slivers coordinate scrolling across multiple scrollable regions without nested scroll conflicts.
- Keyed Widgets for Efficient Diffing — In animated lists or lists where items reorder, provide a ValueKey(item.id) to each list item. Flutter's diffing algorithm uses keys to match old and new widget trees — without keys, it diffs by position and may animate incorrectly or rebuild more widgets than needed.
Code example
// Multi-Level Cache — Stale-While-Revalidate
class UserRepository {
final Map<String, UserProfile> _memCache = {};
final UserLocalCache _diskCache;
final UserApi _api;
UserRepository(this._diskCache, this._api);
Stream<UserProfile?> watchUser(String userId) async* {
if (_memCache.containsKey(userId)) yield _memCache[userId];
final diskCached = await _diskCache.get(userId);
if (diskCached != null) {
_memCache[userId] = diskCached;
yield diskCached;
}
try {
final fresh = await _api.getUser(userId);
_memCache[userId] = fresh;
await _diskCache.save(userId, fresh);
yield fresh;
} catch (e) {
if (diskCached == null && !_memCache.containsKey(userId)) yield null;
}
}
}
// Infinite Scroll ViewModel with cursor pagination
class MessageListViewModel extends StateNotifier<MessageListState> {
final MessageRepository _repo;
bool _isFetching = false;
MessageListViewModel(this._repo) : super(const MessageListState());
Future<void> loadInitial(String channelId) async {
state = state.copyWith(isLoading: true);
final page = await _repo.getMessages(channelId: channelId, limit: 30);
state = state.copyWith(messages: page.items, nextCursor: page.nextCursor,
hasMore: page.hasMore, isLoading: false);
}
Future<void> loadMore() async {
if (_isFetching || !state.hasMore || state.nextCursor == null) return;
_isFetching = true;
state = state.copyWith(isLoadingMore: true);
try {
final page = await _repo.getMessages(channelId: state.channelId!,
cursor: state.nextCursor, limit: 30);
state = state.copyWith(
messages: [...state.messages, ...page.items],
nextCursor: page.nextCursor, hasMore: page.hasMore, isLoadingMore: false);
} finally { _isFetching = false; }
}
}
// Infinite Scroll UI
class MessageListView extends ConsumerStatefulWidget {
final String channelId;
const MessageListView({required this.channelId, super.key});
@override ConsumerState<MessageListView> createState() => _State();
}
class _State extends ConsumerState<MessageListView> {
late final ScrollController _scroll;
@override
void initState() {
super.initState();
_scroll = ScrollController()..addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) =>
ref.read(messageListProvider.notifier).loadInitial(widget.channelId));
}
void _onScroll() {
final pos = _scroll.position;
if (pos.pixels >= pos.maxScrollExtent * 0.8)
ref.read(messageListProvider.notifier).loadMore();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(messageListProvider);
return ListView.builder(
controller: _scroll,
itemCount: state.messages.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == state.messages.length)
return const Center(child: CircularProgressIndicator());
return RepaintBoundary(
key: ValueKey(state.messages[i].id),
child: MessageTile(message: state.messages[i]),
);
},
);
}
@override void dispose() { _scroll.dispose(); super.dispose(); }
}
// Optimized image loading
class UserAvatar extends StatelessWidget {
final String imageUrl;
final double size;
const UserAvatar({required this.imageUrl, this.size = 40, super.key});
@override
Widget build(BuildContext context) {
final dpr = MediaQuery.of(context).devicePixelRatio;
return CachedNetworkImage(
imageUrl: imageUrl, width: size, height: size,
memCacheWidth: (size * dpr).round(),
memCacheHeight: (size * dpr).round(),
imageBuilder: (ctx, ip) => CircleAvatar(backgroundImage: ip, radius: size/2),
placeholder: (ctx, url) => CircleAvatar(radius: size/2,
backgroundColor: Colors.grey[300],
child: const CircularProgressIndicator(strokeWidth: 2)),
errorWidget: (ctx, url, e) => CircleAvatar(radius: size/2,
child: const Icon(Icons.person)),
);
}
}Line-by-line walkthrough
- 1. watchUser emits memory cache, then disk cache, then fresh network data — three emissions for instant + fresh UX
- 2. Stale-while-revalidate: the UI receives up to three values — each one fresher than the last
- 3. loadMore guard: check _isFetching, hasMore, AND nextCursor before fetching — all three required
- 4. State uses cursor not page number — stable pagination even as new messages arrive
- 5. Scroll 80% threshold triggers next page — users get a seamless experience without reaching the end
- 6. RepaintBoundary with ValueKey: identity-tracked separate layer per message
- 7. memCacheWidth uses devicePixelRatio to decode at physical pixels — correct for high-DPI screens
- 8. _isFetching is reset in finally — always released even if the fetch throws
Spot the bug
ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return Column(
children: [
Text(message.content),
CachedNetworkImage(
imageUrl: message.senderAvatarUrl,
width: 40, height: 40,
),
],
);
},
)Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- ListView.builder Flutter Docs (Flutter Official)
- cached_network_image Package (pub.dev)
- CustomScrollView and Slivers (Flutter Official)
- Flutter Performance Best Practices (Flutter Official)
- RepaintBoundary Flutter Docs (Flutter Official)