Back to System Design
System Design
hard
mid

How would you design a dynamic pagination system for infinite scrolling?

Cursor-based pagination + an IntersectionObserver sentinel that triggers loading the next page. Accumulate pages, track hasMore/nextCursor and loading state, virtualize the list, handle errors with retry, and address scroll restoration. Cursor (not offset) keeps it stable under inserts.

5 min read·~15 min to think through

Infinite scroll = detect 'near the end' → load the next page → append → repeat, done robustly.

1. Trigger: IntersectionObserver, not scroll events

Put a sentinel element at the bottom of the list; an IntersectionObserver fires when it enters the viewport:

jsx
const sentinelRef = useRef();
useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting && hasMore && !loading) loadNextPage();
  }, { rootMargin: "200px" });        // prefetch a bit early
  if (sentinelRef.current) observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, [hasMore, loading]);

IntersectionObserver is far better than listening to scroll (which fires constantly and forces you to throttle and measure).

2. Pagination strategy: cursor over offset

  • Cursor-based (?after=<id|timestamp>) — stable when items are inserted/deleted (a feed is constantly changing). No skipped or duplicated items. The right default for infinite scroll.
  • Offset-based (?page=N) — simple, but if rows are inserted above while you scroll, you get duplicates/gaps.

3. State

js
{ items: [],           // accumulated across pages
  nextCursor: null,    // or hasMore: boolean
  status: "idle"|"loading"|"error",  // enum, not a boolean
}

Append on success ([...prev, ...page]), store the new cursor, set hasMore.

4. Virtualize

Infinite scroll accumulates — after 50 pages the DOM has thousands of nodes and dies. Windowing (react-virtuoso handles infinite scroll natively) keeps only visible items mounted. Without virtualization, infinite scroll is a memory leak you scroll into.

5. The robustness details

  • Loading & error states — a spinner at the bottom; on error, an inline "retry" button, not a silent stall.
  • End state — "you've reached the end" when !hasMore.
  • Guard against double-fires — don't trigger another load while one is in flight.
  • De-dupe — cursor pagination helps, but still guard against overlapping items.
  • Scroll restoration — navigating away and back should restore position and loaded pages (cache them).
  • Accessibility — infinite scroll traps keyboard/screen-reader users and hides the footer; offer a "Load more" button as an alternative, announce new content with aria-live.
  • Empty state — zero results on the first page.

infinite scroll vs "Load more"

Mention the trade-off: infinite scroll is engaging but has the footer-unreachable and disorientation problems; a "Load more" button is more accessible and controllable. Best practice is often a hybrid.

The framing

"An IntersectionObserver sentinel at the list bottom triggers the next page — better than throttled scroll listeners. I'd use cursor-based pagination so the feed stays stable under inserts, accumulate items with a status enum and hasMore/nextCursor, and virtualize the list because infinite scroll otherwise grows the DOM unbounded. Then the robustness: loading/error/end states with retry, guarding double-fires, scroll restoration on back-navigation, and accessibility — infinite scroll traps keyboard users, so I'd offer a 'Load more' fallback and aria-live announcements."

Follow-up questions

  • Why IntersectionObserver instead of a scroll event listener?
  • Why cursor pagination over offset for an infinite feed?
  • Why is virtualization necessary, not optional, here?
  • What are the accessibility problems with infinite scroll?

Common mistakes

  • Using throttled scroll listeners instead of IntersectionObserver.
  • Offset pagination on a feed with inserts — duplicates and gaps.
  • No virtualization — the DOM grows unbounded as you scroll.
  • Double-firing the next-page load while one is in flight.
  • Ignoring accessibility and scroll restoration.

Performance considerations

  • Virtualization caps DOM size; IntersectionObserver avoids constant scroll-handler work; rootMargin prefetches before the user hits the end; caching pages avoids refetch on back-navigation.

Edge cases

  • Items inserted/deleted server-side while scrolling.
  • Fast scrolling triggering multiple loads at once.
  • Network error mid-scroll — needs retry, not a dead stop.
  • Navigating away and back — restore position and pages.
  • Reaching the actual end of data.

Real-world examples

  • Social feeds (Twitter/X, Instagram), search results, product listings.
  • react-virtuoso / TanStack Virtual + useInfiniteQuery powering production infinite scroll.

Senior engineer discussion

Seniors use IntersectionObserver + cursor pagination + virtualization as the core, model state with a status enum, handle all the robustness cases, and proactively raise infinite-scroll's accessibility and scroll-restoration problems plus the 'Load more' trade-off.

Related questions