Lesson 28 of 77 intermediate

Caching, Pagination, Lazy Loading & List Performance

Building Lists That Scale to Tens of Thousands of Items

Open interactive version (quiz + challenge)

Real-world analogy

Caching is like a chef's mise en place — prep ingredients before service starts so orders are instant. Pagination is like a library's online catalog that shows 20 books per page instead of all 100,000 at once. Lazy loading is like a conveyor sushi belt — new plates only appear as the current ones are taken. ListView.builder is a clever waiter who only sets the table for seats that are currently visible — not all 10,000 seats in the stadium.

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

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. 1. watchUser emits memory cache, then disk cache, then fresh network data — three emissions for instant + fresh UX
  2. 2. Stale-while-revalidate: the UI receives up to three values — each one fresher than the last
  3. 3. loadMore guard: check _isFetching, hasMore, AND nextCursor before fetching — all three required
  4. 4. State uses cursor not page number — stable pagination even as new messages arrive
  5. 5. Scroll 80% threshold triggers next page — users get a seamless experience without reaching the end
  6. 6. RepaintBoundary with ValueKey: identity-tracked separate layer per message
  7. 7. memCacheWidth uses devicePixelRatio to decode at physical pixels — correct for high-DPI screens
  8. 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?
Two performance issues: one with image memory and one with layout.
Show answer
Bug 1: CachedNetworkImage has no memCacheWidth or memCacheHeight set. If senderAvatarUrl points to a high-resolution image (e.g. 500x500), it will be decoded at full resolution for a 40x40 display — wasting 75x more memory than needed. In a list of 50 messages, this can cause memory pressure and crashes on low-RAM devices. Fix: add memCacheWidth and memCacheHeight proportional to display size and devicePixelRatio. Bug 2: No RepaintBoundary or ValueKey on list items — any state change that causes a list item to rebuild will not be properly isolated in the layer tree, potentially triggering repaints of adjacent items during animations. Add RepaintBoundary(key: ValueKey(message.id), child: ...).

Explain like I'm 5

Imagine a very long conveyor belt of sushi (your list). A lazy chef (ListView.builder) only makes the sushi plates that are currently in front of customers — not all 500 plates for the day. When a customer finishes and moves on, the chef immediately recycles that plate for the next order. Caching is like keeping popular sushi in the fridge so you do not have to cook it from scratch every time. That is why your list is fast.

Fun fact

Flutter's ListView.builder is so efficient that it renders a list of 1,000,000 items with the same memory footprint as 20 items — only the roughly 10 visible items exist in memory at any time. This is the same principle behind virtual DOM in React and RecyclerView in Android. You do not need all items in memory, only the ones the user can currently see.

Hands-on challenge

Build a production-grade infinite scroll list for a real-time message feed: (1) implement cursor pagination with a 30-item page size, (2) trigger next page load at 80% scroll depth with a double-fetch guard, (3) show a bottom loading indicator while fetching, (4) use RepaintBoundary and ValueKey on each item, (5) implement memory plus disk caching for user avatars with memCacheWidth optimization.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery