Efficient Pagination
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 tables | Higher (OFFSET scans rows) | Lower (cursor uses index) |
| Common API style | REST classic | Modern 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)
| State | What to render |
|---|---|
| Initial load | Full-screen skeleton or spinner |
| Subsequent page loading | Footer spinner at end of list |
| End of list | "No more posts" footer (optional) |
| Empty list | Empty-state UI ("No posts yet") |
| First-page error | Retry button covering whole screen |
| Next-page error | Inline retry at the footer; keep loaded items |
| Pull-to-refresh | Reset 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
| Situation | Approach |
|---|---|
| Most paginated lists | ListView.builder + state notifier (as above) |
| Quick win, less boilerplate | infinite_scroll_pagination package |
| Lists with sticky headers + pagination | CustomScrollView + sliver-based pagination |
| Bidirectional pagination (chat history above current view) | Manual scroll position management + cursor in both directions |
| Search-as-you-type with pagination | Debounce 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
-
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 likelast_idorlast_created_at) lets the backend return "the next 20 items after this one" deterministically. -
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).
-
What's the trade-off of triggering on a
ScrollNotificationvs aScrollControllerlistener?NotificationListener<ScrollNotification>is self-contained — wraps the list, fires on scroll, no manual controller management.ScrollControllergives 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. -
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.