Efficient Pagination

Medium PriorityAsked in ~60% of senior interviews

4 min read

Performance & Networking

Offset pagination (?page=2&limit=20)Cursor pagination (?cursor=abc&limit=20)
Stability under inserts/deletes❌ Items shift, duplicates / skips appear✅ Cursor points to a stable anchor
Random access (jump to page 5)✅ Easy❌ Sequential only
Backend cost on large tablesHigher (OFFSET scans rows)Lower (cursor uses index)
Common API styleREST classicModern APIs, GraphQL Relay style

Cursor is the right default for feeds, chat, anything append-mostly. Offset is fine for static catalogues.


Code in action — state notifier + ListView.builder

// State
class PageState<T> {
  const PageState({this.items = const [], this.cursor, this.hasMore = true,
                   this.isLoading = false, this.error});
  final List<T> items;
  final String? cursor;
  final bool hasMore;
  final bool isLoading;
  final Object? error;

  PageState<T> copyWith({List<T>? items, String? cursor, bool? hasMore,
                          bool? isLoading, Object? error, bool clearError = false}) =>
      PageState(
        items: items ?? this.items,
        cursor: cursor ?? this.cursor,
        hasMore: hasMore ?? this.hasMore,
        isLoading: isLoading ?? this.isLoading,
        error: clearError ? null : (error ?? this.error),
      );
}

// Notifier
class FeedNotifier extends StateNotifier<PageState<Post>> {
  FeedNotifier(this._api) : super(const PageState()) {
    loadNext();
  }
  final ApiClient _api;

  Future<void> loadNext() async {
    if (state.isLoading || !state.hasMore) return;
    state = state.copyWith(isLoading: true, clearError: true);

    try {
      final page = await _api.fetchFeed(cursor: state.cursor, limit: 20);
      state = state.copyWith(
        items: [...state.items, ...page.items],
        cursor: page.nextCursor,
        hasMore: page.nextCursor != null,
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(isLoading: false, error: e);
    }
  }

  Future<void> refresh() async {
    state = const PageState();
    await loadNext();
  }
}
// Widget — trigger near the bottom of the viewport
class FeedList extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final s = ref.watch(feedProvider);
    final notifier = ref.read(feedProvider.notifier);

    return RefreshIndicator(
      onRefresh: notifier.refresh,
      child: NotificationListener<ScrollNotification>(
        onNotification: (n) {
          if (n.metrics.pixels >= n.metrics.maxScrollExtent * 0.8) {
            notifier.loadNext();              // idempotent — guarded inside
          }
          return false;
        },
        child: ListView.builder(
          itemCount: s.items.length + (s.hasMore ? 1 : 0),
          itemBuilder: (ctx, i) {
            if (i == s.items.length) {
              if (s.error != null) {
                return _ErrorFooter(error: s.error!, onRetry: notifier.loadNext);
              }
              return const Center(child: Padding(
                padding: EdgeInsets.all(16),
                child: CircularProgressIndicator(),
              ));
            }
            return PostCard(s.items[i]);
          },
        ),
      ),
    );
  }
}

UI states to handle (none of them optional)

StateWhat to render
Initial loadFull-screen skeleton or spinner
Subsequent page loadingFooter spinner at end of list
End of list"No more posts" footer (optional)
Empty listEmpty-state UI ("No posts yet")
First-page errorRetry button covering whole screen
Next-page errorInline retry at the footer; keep loaded items
Pull-to-refreshReset to fresh first page, show RefreshIndicator

Skipping any of these is the difference between a polished feed and one that feels broken.


When to use which approach

SituationApproach
Most paginated listsListView.builder + state notifier (as above)
Quick win, less boilerplateinfinite_scroll_pagination package
Lists with sticky headers + paginationCustomScrollView + sliver-based pagination
Bidirectional pagination (chat history above current view)Manual scroll position management + cursor in both directions
Search-as-you-type with paginationDebounce input + reset pagination per query

Common mistakes to avoid

❌ Triggering loadNext from a scroll listener WITHOUT idempotency guard
   Scroll fires constantly; you'll launch dozens of overlapping requests.
   ✅ Guard inside the notifier: if (isLoading || !hasMore) return;

❌ Offset pagination on a feed that mutates
   User scrolls, someone posts at the top → next page shows duplicates or skips.
   ✅ Cursor pagination

❌ Not handling next-page errors
   Failed fetch wipes out the loaded items or just hangs forever.
   ✅ Keep items, show inline error footer with retry

❌ Loading all items into one massive non-builder ListView
   ListView(children: [for in items ItemTile]) — builds all at once, blows memory
   ✅ ListView.builder

❌ Forgetting to deduplicate when refreshing
   refresh() returns same items + previous IDs → duplicate keys
   ✅ Reset state cleanly before re-fetching

❌ Triggering near 100% of scroll
   By the time the user is at the bottom, the new content isn't loaded yet
   → jarring "spinner pop" after a moment of empty
   ✅ Trigger at 70-80% so loading overlaps the remaining content

Interview follow-ups

  1. Why is cursor pagination better than offset for feeds? Offset (LIMIT 20 OFFSET 40) is positional — when items are inserted/deleted at the top, the user sees duplicates or skips between pages. A cursor (an opaque token pointing to a stable anchor like last_id or last_created_at) lets the backend return "the next 20 items after this one" deterministically.

  2. How do you handle errors mid-pagination? Keep the already-loaded items visible, show an inline error footer with a retry button, and only block the whole list if the first page failed. Surface offline state distinctly (often via connectivity-aware caching).

  3. What's the trade-off of triggering on a ScrollNotification vs a ScrollController listener? NotificationListener<ScrollNotification> is self-contained — wraps the list, fires on scroll, no manual controller management. ScrollController gives you fine-grained access (programmatic scroll, current offset) but you must dispose it and attach it to the list. For pagination triggering, either works; controller is heavier.

  4. How would you handle pagination + search + filters together? Treat the query (search text + filters) as part of the pagination key. When any input changes, reset state to a fresh first page with the new query. Debounce text input so each keystroke doesn't reset. State notifier holds (items, cursor, currentQuery); a query change clears items and re-fetches.


How helpful was this content?

Please sign in to rate this article.