Implement infinite scrolling
IntersectionObserver on a sentinel near the bottom triggers loading the next page; cursor pagination (offset breaks under inserts); accumulate pages; show loading/empty/error states; restore scroll on back-navigation via cached pages (React Query); virtualize once the list is long. Bonus: 'load on hover near end' for desktop polish.
Infinite scrolling is scroll-position-triggered pagination. Build it on cursor pagination and IntersectionObserver — not scroll-event listeners.
1. The minimal implementation
function Feed() {
const [pages, setPages] = useState([]);
const [cursor, setCursor] = useState(null);
const [status, setStatus] = useState("idle");
const sentinelRef = useRef();
const loadMore = useCallback(async () => {
if (status === "loading") return;
setStatus("loading");
try {
const { items, nextCursor } = await fetchPage(cursor);
setPages((p) => [...p, items]);
setCursor(nextCursor);
setStatus(nextCursor ? "idle" : "end");
} catch (e) {
setStatus("error");
}
}, [cursor, status]);
useEffect(() => {
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && status === "idle") loadMore();
}, { rootMargin: "300px" });
if (sentinelRef.current) obs.observe(sentinelRef.current);
return () => obs.disconnect();
}, [loadMore, status]);
const items = pages.flat();
return (
<>
{items.map((it) => <Item key={it.id} item={it} />)}
{status === "loading" && <Spinner />}
{status === "error" && <button onClick={loadMore}>Retry</button>}
<div ref={sentinelRef} />
</>
);
}2. Cursor, not offset
Offset pagination (?page=2&limit=20) breaks when items are inserted at the top — the user sees duplicates or skips. Cursor pagination uses an opaque token returned with each page:
GET /feed?cursor=eyJ...
→ { items: [...], nextCursor: "eyJ..." | null }null cursor → end of list.
3. IntersectionObserver, not scroll events
Scroll-event listeners fire at high frequency, force layout reads, and cost INP. IntersectionObserver:
- Fires only when the sentinel enters/exits viewport.
- No main-thread cost per scroll frame.
rootMargin: "300px"triggers slightly before the sentinel is visible, so loading feels instant.
4. With React Query
Production-ready version is short:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
getNextPageParam: (last) => last.nextCursor,
});
// IntersectionObserver triggers fetchNextPage()Benefits:
- Cache restores on back-navigation.
- Built-in loading / error / refetch.
getNextPageParam: nullends pagination cleanly.
5. Virtualization for long lists
Without virtualization, scrolling 10,000 DOM nodes destroys memory and INP. With react-window / react-virtual, only visible items are in the DOM.
Note: virtualization + infinite scroll needs the virtualizer to know the total scroll size; some libraries assume known item count. For unknown end, use estimated sizes and grow as more pages load.
6. States — all of them
- Idle — between fetches.
- Loading — show a skeleton or spinner at the bottom.
- Empty (first page returned nothing) — distinguish from end-of-list.
- End — "You're all caught up."
- Error — show a retry inline.
7. Scroll restoration
Browser back from a detail page should restore scroll position with the same pages loaded. React Query caches by query key, so the data is hot on back-nav; you just need to restore scroll:
- Native:
history.scrollRestoration = "auto"(default). - React Router:
<ScrollRestoration />(Remix) or manual.
8. Deduplication
If the same item could appear in two pages (rare but possible), dedupe in render:
const seen = new Set();
const items = pages.flat().filter((it) => !seen.has(it.id) && seen.add(it.id));9. Accessibility
Infinite scroll has known a11y issues — there's no "end" of the page. Mitigations:
- Provide a non-infinite alternative ("Page 2" link) for screen-reader users.
- Announce "Loading more results" via a polite live region.
- Don't hide the footer — push it past the infinite content or place above.
- Allow keyboard users to stop / pause loading.
10. Common pitfalls
- Offset pagination with new posts arriving — duplicates/skips.
- Scroll-event listener instead of IntersectionObserver — INP regressions.
- Loading more than one page before user reaches it — wasted bandwidth.
- No "end" state — spinner forever after the last page.
- Memory blow-up without virtualization.
Interview framing
"IntersectionObserver on a sentinel at the bottom, cursor pagination on the server. When the sentinel enters viewport (with a 300px rootMargin so it triggers ahead of time), fetch the next page using the cursor from the previous response. Accumulate pages, render flat. Track explicit states: idle, loading, empty, end, error. For production I'd use useInfiniteQuery — it handles caching, restoration, deduplication of in-flight requests. For long feeds, add virtualization (react-window / react-virtual) so the DOM only holds visible items. Accessibility considerations: provide a non-infinite alternative for screen readers, announce loading via live region, don't strand the footer."
Follow-up questions
- •Why IntersectionObserver over scroll events?
- •Why cursor over offset?
- •How does this interact with virtualization?
- •What are the a11y concerns with infinite scroll?
Common mistakes
- •Scroll-event listener with throttling.
- •Offset pagination on dynamic feeds.
- •No end state → infinite spinner.
- •No virtualization on very long lists.
- •Loading multiple pages ahead unnecessarily.
Performance considerations
- •Avoid scroll listeners; use IntersectionObserver. Virtualize long lists. Memoize items. Prefetch one page ahead.
Edge cases
- •Empty first page.
- •End reached.
- •Network error mid-scroll.
- •Back-navigation restoring scroll.
- •Stuck sentinel when page is short.
Real-world examples
- •Twitter/X timeline, Facebook feed, Instagram, Reddit.
- •TanStack Query's useInfiniteQuery.