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.
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:
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
{ 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.