Back to Machine Coding
Machine Coding
easy
mid

How would you implement infinite scrolling from scratch?

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.

5 min read·~25 min to think through

Infinite scrolling is scroll-position-triggered pagination. Build it on cursor pagination and IntersectionObserver — not scroll-event listeners.

1. The minimal implementation

jsx
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:

ts
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:

jsx
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: null ends 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:

js
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.

Senior engineer discussion

Seniors use IntersectionObserver + cursor pagination + a query library, plan for virtualization, design explicit states, and consider a11y — they don't ship a forever-spinning scroller.

Related questions