Summary
A production feed uses keyset/cursor pagination — not offset — so skips and duplicates don't occur as data shifts. An `IntersectionObserver` sentinel triggers the next page load automatically. TanStack Virtual keeps the rendered DOM bounded regardless of list length. A 'load more' button preserves keyboard and screen-reader access.
Jump to the interview angleAn infinite-scroll feed auto-fetches the next page as the user nears the bottom, creating the appearance of a continuous list.
The two foundational choices drive every other decision:
- Cursor vs offset pagination. Offset pagination (
LIMIT n OFFSET k) produces skips and duplicates when rows are inserted while the user scrolls. A keyset cursor (e.g.after: "2xZ9q") encodes the last item's sort key; inserts don't shift it. - Observer vs scroll event. A scroll-event listener fires hundreds of times per scroll and lives on the main thread. An
IntersectionObserversentinel fires once, off the critical path, when an invisible marker enters the viewport.
IntersectionObserver sentinel hook
The hook attaches an observer to a ref, calls onIntersect once when visible, and disconnects on unmount. Wire the ref to an empty <div> after the list.
import { useEffect, useRef } from "react";
interface Options {
onIntersect: () => void;
enabled?: boolean;
rootMargin?: string;
}
/** Attach to a sentinel <div> at the bottom of the list. */
export function useIntersectionSentinel({
onIntersect,
enabled = true,
rootMargin = "200px",
}: Options) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onIntersect();
},
{ rootMargin },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [enabled, onIntersect, rootMargin]);
return ref;
}
// --- Usage with TanStack Query v5 infinite query ---
import { useInfiniteQuery } from "@tanstack/react-query";
interface Post { id: string; body: string; }
interface Page { items: Post[]; nextCursor: string | null; }
async function fetchFeed({ pageParam = null }: { pageParam?: string | null }): Promise<Page> {
const url = pageParam ? `/api/feed?after=${pageParam}` : "/api/feed";
const res = await fetch(url);
if (!res.ok) throw new Error("fetch failed");
return res.json();
}
export function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ["feed"],
queryFn: fetchFeed,
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
const sentinelRef = useIntersectionSentinel({
onIntersect: fetchNextPage,
enabled: hasNextPage && !isFetchingNextPage,
});
const posts = data?.pages.flatMap((p) => p.items) ?? [];
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.body}</li>
))}
</ul>
{/* Sentinel — invisible; triggers next fetch */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Accessible fallback for keyboard / SR users */}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading…" : "Load more"}
</button>
)}
</div>
);
}rootMargin: '200px' pre-fetches before the sentinel is fully visible. The button fallback ensures keyboard and screen-reader users can advance the feed.
Virtualize once the list exceeds ~200 rows
Without windowing, every page appended keeps its DOM nodes alive. At 1,000+ items this causes layout thrash and high memory. Add @tanstack/react-virtual — measure each row, render only the visible window (~30 nodes), and set a spacer div equal to the total estimated height. Scroll restoration: save scrollTop in session storage on popstate and restore after the initial page hydrates.
Interview angle
Interviewers probe why offset pagination breaks at scale, how the sentinel avoids scroll event spam, and what windowing buys. Name the concrete tradeoffs: keyset cursors prevent skip/duplicate on insert, IntersectionObserver is passive, TanStack Virtual caps DOM nodes at ~30.
Soundbite: "Cursor for correctness, sentinel for triggering, virtualizer for DOM health, 'load more' for keyboard users."
Key terms
- keyset pagination
- Paginate by encoding the last-seen row's sort key as a cursor; immune to inserts shifting offsets.
- sentinel element
- An empty DOM node at the list bottom; `IntersectionObserver` fires when it enters the viewport.
- list virtualization
- Render only visible rows plus a small overscan; TanStack Virtual manages row measurement and offsets.
- scroll restoration
- Saving and re-applying scroll position so back-navigation returns users to their place in the feed.
- IntersectionObserver
- Browser API that fires a callback when a target element crosses a viewport threshold; no scroll listener needed.