Pagination Strategies and Infinite Scroll with IntersectionObserver
Implement page-based and cursor-based pagination, then build a robust infinite scroll experience using IntersectionObserver, handling duplicate prevention, retries, and accessibility correctly.
Paging through large datasets is one of the most common UI needs, and there is more than one correct way to do it. Page-based pagination (page=3&pageSize=20) is simple and lets users jump to an arbitrary page, but breaks down when the underlying data changes between requests (rows inserted/deleted shift what 'page 3' means). Cursor-based pagination (an opaque token pointing 'after this specific item') is stable under concurrent writes and is what most large-scale APIs (GitHub, Stripe, Slack) use for exactly that reason. Infinite scroll builds on top of either pagination style, replacing explicit page controls with automatic loading as the user approaches the bottom of the list — implemented correctly with IntersectionObserver rather than scroll-event math.
Page pagination vs. cursor pagination
Page (offset) pagination uses a page number and page size: GET /items?page=3&pageSize=20 conceptually becomes OFFSET 40 LIMIT 20 on the backend. It's intuitive, supports 'jump to page N', and total page count is easy to compute from a total row count. Its weakness: if a row is inserted before page 3 while the user is paging, offset 40 now points to a different row than before — items can be skipped or duplicated across page loads. It also gets slower on the backend for very large offsets, since the database must still count/skip all preceding rows.
Cursor pagination replaces the offset with an opaque cursor — typically an encoded reference to the last item seen (e.g. its id or a compound sort key): GET /items?after=eyJpZCI6NDJ9&limit=20. The server returns the next N items strictly after that cursor, which is stable even if earlier rows are inserted or deleted, and stays fast at any depth because it's an indexed 'WHERE id > X' lookup rather than a large OFFSET scan. Its weakness: you generally can't jump to an arbitrary page number, only 'next' (and sometimes 'previous', if the API supports a reverse cursor) — which is exactly why it pairs naturally with infinite scroll, where users never jump to page N anyway.
• Page pagination: page + pageSize, supports random access to page N, can skip/duplicate rows under concurrent writes • Cursor pagination: after=<cursor> + limit, stable under inserts/deletes, stays fast at depth, no random page jump • Choose cursor pagination for feeds/infinite scroll; page pagination for tables where users navigate to a specific page
Infinite scroll with IntersectionObserver
The naive way to implement infinite scroll is a scroll event listener that checks scrollTop + clientHeight >= scrollHeight - threshold. This works but fires on every scroll frame, requires manual throttling, and forces synchronous layout reads (scrollHeight, clientHeight) that can hurt scroll performance. IntersectionObserver is the correct modern primitive: you place an invisible 'sentinel' element after the last loaded item, observe it, and the browser notifies you — off the main scroll thread, asynchronously — exactly when that sentinel becomes visible in the viewport. No polling, no manual math, no scroll-jank risk.
The pattern: render the list, then render a small sentinel div at the bottom, and attach an IntersectionObserver to it. When the observer's callback reports isIntersecting: true, trigger the next page fetch. Once the fetch resolves, append results and the sentinel naturally moves further down, ready to trigger the next load when scrolled into view again.
• Sentinel: a lightweight element rendered after the last item, purely as an observation target • IntersectionObserver's rootMargin lets you trigger loading *before* the sentinel is fully on-screen (e.g. '200px' pre-fetches while the user still has to scroll a bit) • Disconnect/unobserve the sentinel while a fetch is already in flight, to avoid firing the same load twice
Duplicate prevention and retry
Two distinct duplicate risks exist: (1) triggering the *same* page load twice (e.g. the observer fires again before the in-flight request resolves and updates the sentinel's position), and (2) the *data itself* containing duplicate items across pages, which can genuinely happen with offset pagination under concurrent inserts. Guard against (1) with an isFetchingRef/state flag checked before triggering a new load, and against (2) by de-duplicating incoming items by id when appending to the accumulated list (e.g. merge into a Map keyed by id rather than blindly concatenating arrays).
Network requests fail. An infinite-scroll list needs a retry affordance: when a page load fails, show an inline 'Failed to load more — Retry' control at the sentinel position rather than silently stalling (which looks identical to 'you've reached the end' from the user's perspective) or endlessly retrying automatically (which can hammer a struggling backend). A manual retry button, plus perhaps one or two automatic retries with backoff for transient errors, balances resilience with not making things worse.
• Guard concurrent triggers with an isLoading flag, checked and set before firing a fetch • De-duplicate by stable item id when merging new pages into accumulated state (Map<id, item>, not array concat) • On failure, show a retry control distinct from both 'loading' and 'end of list' states
Accessibility for infinite scroll
Infinite scroll is notoriously hostile to keyboard and screen-reader users if built carelessly: content appears with no announcement, focus can get lost, and reaching the footer becomes impossible if new content keeps loading before scroll gets there. Use an ARIA live region (aria-live="polite") to announce 'Loaded 20 more items' when a new page arrives, so screen reader users know content changed without being interrupted mid-sentence by an assertive announcement.
Always provide a non-scroll-dependent way to reach the end — a 'Load more' button as a fallback/alternative trigger alongside the automatic observer-based loading, and/or a visible, reachable footer/pagination summary ('Showing 40 of 312 results'). Never trap a user in an infinitely-loading list with no way to reach page footer content like a global footer or 'contact us' link.
• aria-live="polite" region announcing new content, not aria-live="assertive" (which interrupts) • Provide a manual 'Load more' button as an accessible alternative to scroll-triggered loading • Ensure the page footer/global navigation remains reachable — don't let infinite content prevent reaching it • Preserve focus position when new items are appended (never steal focus away from what the user was doing)
IntersectionObserver sentinel hook signature
rootMargin: '200px' extends the intersection root's bounding box, so the callback fires while the sentinel is still 200px below the visible viewport — content pre-loads before the user hits the literal bottom. options.enabled lets the caller disable observation while a fetch is already in flight, preventing duplicate triggers.
function useInfiniteScrollSentinel(
onIntersect: () => void,
options?: { rootMargin?: string; enabled?: boolean }
) {
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (options?.enabled === false) return;
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) onIntersect(); },
{ rootMargin: options?.rootMargin ?? "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [onIntersect, options?.enabled, options?.rootMargin]);
return sentinelRef;
}Code Examples
The cursor is opaque to the client — it's just a token the server understands (often an encoded id + sort key). nextCursor: null is the unambiguous 'end of list' signal, distinct from an empty items array on a page that still has more after it (e.g. a page fully consumed by items that were subsequently deleted).
if (cursor) params.set("after", cursor);
isFetchingRef (a ref, not state) synchronously blocks a second concurrent loadMore call from firing before the first one finishes — using state for this guard would be unreliable because state updates are asynchronous and batched. seenIds deduplicates by item id across pages, defending against overlapping results from offset-style backends or retried requests. status transitions to 'done' only when the server explicitly signals no more pages via nextCursor === null.
if (isFetchingRef.current || state.status === "done") return;
Common mistake: Using a state variable (isLoading) instead of a ref for the concurrency guard — because setState is asynchronous, a second call to loadMore triggered synchronously (e.g. two IntersectionObserver callbacks in the same tick) can read the stale 'false' value before the first call's setState({status:'loading'}) has actually applied.
The sentinel div is aria-hidden (it's a pure layout/observation trick, not real content) while a real, focusable 'Load more' button provides an accessible, keyboard-operable alternative that doesn't depend on scroll position at all. The aria-live region gives screen-reader users a polite announcement of new content without interrupting their current reading position. The observer is disabled (enabled: false) while a fetch is in flight or the list is done, preventing redundant triggers. Expected result: Scrolling near the bottom auto-loads more items; keyboard/screen-reader users can also just activate the always-present 'Load more' button.
enabled: status !== "loading" && status !== "done"
interface Page<T> {
items: T[];
nextCursor: string | null; // null means no more pages
}
async function fetchPage<T>(
cursor: string | null,
limit: number,
signal: AbortSignal
): Promise<Page<T>> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("after", cursor);
const res = await fetch(`/api/items?${params.toString()}`, { signal });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json(); // { items: [...], nextCursor: "..." | null }
}interface InfiniteState<T> {
items: T[];
nextCursor: string | null;
status: "idle" | "loading" | "error" | "done";
}
function useInfiniteList<T extends { id: string }>(
fetchPageFn: (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>
) {
const [state, setState] = useState<InfiniteState<T>>({
items: [], nextCursor: null, status: "idle",
});
const seenIds = useRef(new Set<string>());
const isFetchingRef = useRef(false); // guards against duplicate concurrent triggers
const loadMore = useCallback(async () => {
if (isFetchingRef.current || state.status === "done") return;
isFetchingRef.current = true;
setState((s) => ({ ...s, status: "loading" }));
const controller = new AbortController();
try {
const page = await fetchPageFn(state.nextCursor, controller.signal);
// De-duplicate: only keep items whose id we haven't already accumulated
const freshItems = page.items.filter((item) => {
if (seenIds.current.has(item.id)) return false;
seenIds.current.add(item.id);
return true;
});
setState((s) => ({
items: [...s.items, ...freshItems],
nextCursor: page.nextCursor,
…function InfiniteFeed<T extends { id: string }>({ renderItem, fetchPageFn }: {
renderItem: (item: T) => React.ReactNode;
fetchPageFn: (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;
}) {
const { items, status, loadMore } = useInfiniteList(fetchPageFn);
const sentinelRef = useInfiniteScrollSentinel(loadMore, {
enabled: status !== "loading" && status !== "done",
});
useEffect(() => { loadMore(); }, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial page only
return (
<div>
<ul>{items.map((item) => <li key={item.id}>{renderItem(item)}</li>)}</ul>
{/* Screen readers hear this update politely, without interrupting */}
<div aria-live="polite" className="sr-only">
{status === "loading" ? "Loading more items" : `${items.length} items loaded`}
</div>
{status === "error" && (
<button onClick={loadMore}>Failed to load more — Retry</button>
)}
{status !== "done" && status !== "error" && (
<>
<div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />
{/* Accessible, non-scroll-dependent fallback */}
<button onClick={loadMore} disable
…IntersectionObserver-driven infinite scroll
The sentinel sits just past the last loaded item; its intersection with an expanded viewport (via rootMargin) triggers the next cursor-paginated fetch.
Viewport
┌───────────────────────────┐
│ item 1 │
│ item 2 │
│ item 3 │ <- visible items
│ item 4 │
├───────────────────────────┤ <- viewport bottom edge
│ item 5 (below the fold) │
│ item 6 │
│ [sentinel] ◄── rootMargin: "200px" means the observer
│ fires while sentinel is still
│ ~200px below the visible viewport
└───────────────────────────┘
│
│ IntersectionObserver reports isIntersecting: true
▼
isFetchingRef.current? ──yes──► ignore (already loading, avoid duplicate trigger)
│ no
▼
fetchPage(nextCursor) ──► de-duplicate by id ──► append to items ──► move sentinel down
│
▼
nextCursor === null? ──yes──► status: "done", stop observing
│ no
▼
ready to trigger again when sentinel re-enters the (expanded) viewportImportant Rules
Prefer cursor pagination over offset/page pagination for feeds and infinite scroll — it stays correct and fast under concurrent inserts/deletes.
Use IntersectionObserver, not scroll-event math, to detect when to load the next page.
Guard against duplicate concurrent fetch triggers with a ref-based flag, not a state-based one.
De-duplicate accumulated items by stable id when merging pages, never assume pages are disjoint.
Always provide a non-scroll-dependent 'Load more' control and an aria-live announcement alongside automatic scroll-triggered loading.
Distinguish 'loading', 'error' (with retry), and 'done' (end of list) as separate, clearly signaled states.
Where this shows up
Social media feeds (Twitter/X, Instagram, Mastodon), chat history scrollback, e-commerce 'load more products', and API explorers/log viewers all use cursor pagination plus IntersectionObserver-driven loading. Large-scale APIs (Stripe, GitHub, Slack) universally expose cursor-based pagination on list endpoints for exactly the stability and performance reasons covered here — recognizing an 'after'/'starting_after'/'page_token' parameter in an API's docs is a direct signal to reach for this exact client-side pattern.
window.addEventListener('scroll', () => { if (nearBottom()) loadMore() })
Attach an IntersectionObserver to a sentinel element rendered after the last item.
Scroll listeners fire on every frame, require manual throttling, and force layout reads (scrollHeight/clientHeight) synchronously — IntersectionObserver does the same job asynchronously and far more efficiently, off the scroll thread.
setState(s => ({ items: [...s.items, ...page.items] })) with no dedup
Filter page.items against a Set of already-seen ids before appending.
Offset-based backends (and retried requests) can return overlapping items across pages; blindly concatenating produces visible duplicate rows in the rendered list, and duplicate React keys.
Infinite scroll with no 'Load more' button and no aria-live region
Pair automatic scroll-triggered loading with an always-present, keyboard-accessible 'Load more' button and a polite live-region announcement.
Keyboard-only and screen-reader users may never trigger scroll-based loading at all, and even when they do, silent DOM changes with no announcement leave them unaware new content appeared.
Why is cursor pagination generally preferred over offset pagination for an infinite-scroll feed? A. Cursor pagination is easier to implement on the frontend B. Cursor pagination stays correct and performant even when items are inserted or deleted between requests, unlike offset pagination which can skip or duplicate rows C. Offset pagination cannot be used with React at all D. Cursor pagination always returns fewer items per page
B. Cursor pagination stays correct and performant even when items are inserted or deleted between requests, unlike offset pagination which can skip or duplicate rows — Offset pagination's 'page 3' is defined relative to the current full ordering, so concurrent inserts/deletes shift which rows fall on which page between requests. Cursor pagination anchors to a specific item ('after this cursor'), which stays stable and also avoids large OFFSET scans at depth.
What is the main advantage of IntersectionObserver over a scroll event listener for triggering infinite-scroll page loads? A. IntersectionObserver can only be used with cursor pagination B. It reports visibility changes asynchronously off the main scroll thread, avoiding manual throttling and synchronous layout reads on every scroll frame C. Scroll event listeners are deprecated and no longer work in modern browsers D. IntersectionObserver automatically de-duplicates fetched items
B. It reports visibility changes asynchronously off the main scroll thread, avoiding manual throttling and synchronous layout reads on every scroll frame — IntersectionObserver lets the browser notify you when an element's visibility crosses a threshold, without your code polling scrollTop/scrollHeight on every scroll event or needing manual throttling — it's both simpler and more performant.
Why should the concurrent-fetch guard (isFetchingRef) be a ref rather than a piece of React state? A. Refs are faster to read than state in every situation B. State updates are asynchronous/batched, so a second trigger arriving before a state update has applied could read a stale 'not loading' value and fire a duplicate request; a ref updates synchronously and immediately C. React does not allow booleans to be stored in useState D. Refs automatically reset to false after every render
B. State updates are asynchronous/batched, so a second trigger arriving before a state update has applied could read a stale 'not loading' value and fire a duplicate request; a ref updates synchronously and immediately — Because setState doesn't apply synchronously, two triggers occurring close together (e.g. two IntersectionObserver callbacks) could both read the same stale 'false' loading state before either update commits. A ref is mutated synchronously and immediately reflects the true in-flight status for the very next check.
Add bidirectional infinite scroll to the InfiniteFeed example: support loading older items when scrolling down (already covered) AND loading newer items when scrolling up past the top (e.g. for a live chat/feed that receives new items while the user has scrolled back through history), using a second sentinel and a prevCursor alongside nextCursor.
Add a top sentinel observed with its own IntersectionObserver; on trigger, fetch with a prevCursor, prepend de-duplicated results to state.items, and in a layout effect after the DOM updates, set scrollTop to (new scrollHeight - old scrollHeight) + old scrollTop to keep the user's visual position stable.
You'll need to track and preserve scroll position when prepending items above the current viewport — prepending shifts all existing content down, which without correction causes a visible jump; capture scrollHeight before prepending and restore the delta after the DOM updates.