advancedVery common6 min read · Updated Jul 19, 2026

Build an Infinite Scroll Controller

Build infinite scroll in JavaScript with IntersectionObserver — the sentinel pattern, in-flight and end-of-data guards, error retry, and the virtualization boundary.


The problem

Build the loading logic for an infinite feed: when the user nears the bottom, fetch the next page and append it — without duplicate fetches, without spinning forever at the end of the data, and without falling over when a page request fails.

This is the machine-coding question every feed-owning team (Meta, Airbnb, Pinterest…) reaches for, because the naive version is easy and every hardening step is a real production incident: the double-fetch, the retry-forever-at-the-end, the scroll-handler-jank. The modern implementation also has a right answer the question screens for: IntersectionObserver + a sentinel element, not scroll math.

The old way — and why it's the wrong answer now

// ❌ the 2015 answer
window.addEventListener("scroll", () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 300) {
    loadMore();
  }
});

Three strikes: scroll fires continuously on the main thread (needs throttling just to be survivable); the geometry reads (offsetHeight, scrollY) can force sync layout in a hot path; and it silently breaks when the feed lives in a scrollable container rather than the window. IntersectionObserver inverts control — the browser tells you when an element approaches the viewport, off the main thread, with zero per-scroll JS. Leading with "scroll listeners are the legacy answer, here's why" is the strongest possible opening.

The sentinel pattern

Place an invisible element after the list. When it approaches the viewport, load more. The list grows, the sentinel moves down, re-arms automatically:

function createInfiniteScroll({
  fetchPage,             // (cursor) => Promise<{ items, nextCursor }>
  onItems,               // (items) => void — append to the DOM/UI
  onStateChange = () => {}, // "idle" | "loading" | "error" | "done"
  sentinel,              // the element to watch
  rootMargin = "600px",  // start loading BEFORE the user hits the bottom
}) {
  let cursor = null;      // opaque server cursor; null = first page
  let state = "idle";     // the state machine IS the exercise
 
  const setState = (next) => {
    state = next;
    onStateChange(next);
  };
 
  async function loadMore() {
    if (state === "loading" || state === "done") return; // THE guard
    setState("loading");
    try {
      const { items, nextCursor } = await fetchPage(cursor);
      cursor = nextCursor;
      onItems(items);
      if (nextCursor == null || items.length === 0) {
        setState("done");
        observer.disconnect();   // nothing left to watch for
      } else {
        setState("idle");
        // If the new items didn't fill the viewport, the sentinel is
        // STILL visible — check and keep loading (the short-page case)
        maybeLoadAgain();
      }
    } catch (error) {
      setState("error");         // do NOT retry automatically — see below
    }
  }
 
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) loadMore();
    },
    { rootMargin }               // e.g. 600px early — perceived-instant loading
  );
 
  function maybeLoadAgain() {
    // Re-observing forces a fresh intersection check next frame
    observer.unobserve(sentinel);
    observer.observe(sentinel);
  }
 
  observer.observe(sentinel);
 
  return {
    retry() {                     // wired to a "Retry" button in the error row
      if (state === "error") {
        setState("idle");
        loadMore();
      }
    },
    destroy() {
      observer.disconnect();
    },
  };
}

Wiring

const controller = createInfiniteScroll({
  sentinel: document.querySelector("#feed-sentinel"),
  fetchPage: (cursor) =>
    fetch(`/api/feed${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`)
      .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
  onItems: appendRows,
  onStateChange: renderFooter,   // spinner / "Retry" button / "You're all caught up"
});

The four guards that are the actual question

  1. In-flight guard — the observer can fire again while a page is loading (slow network + fast scroll). Without state === "loading" short-circuiting, you fetch the same page twice and render duplicates. This is the #1 planted scenario, and it's the same "a promise is already running" discipline as the typeahead controller — state machines, not hope.
  2. End-of-data — when the server says done (nextCursor == null), transition to "done" and disconnect. The naive version leaves the observer armed and hammers the API with empty-page requests forever — a real incident class ("why is /feed getting 40 rps of page-97 requests?").
  3. Error state without auto-retry — a failing endpoint plus an armed observer is a retry storm (the thundering-herd lesson in UI form). Park in "error", render a retry button; if you do auto-retry, it must be backoff-limited.
  4. The short-page case — viewport is 1400px tall, page size is 5 short rows: the sentinel never leaves the viewport, and depending on timing you get no new intersection event. The maybeLoadAgain re-observe forces a re-check so the feed fills the screen. Subtle, real, and the guard fewest candidates know.

Cursor vs offset pagination — the one systems paragraph

?page=3 breaks under mutation: a new item posted while the user reads shifts every offset, producing duplicate or skipped rows at page boundaries — the classic feed bug. Opaque server cursors (?cursor=abc, encoding "after item X") are stable under inserts. The controller above treats the cursor as opaque by design — client-side page math is a smell. One paragraph of this converts the question from DOM trivia to a systems conversation.

Edge cases interviewers probe

  • Rapid scroll during load — guard #1; expect to trace the double-fetch timeline.
  • Empty first page — straight to "done", render an empty state — falls out of the state machine if items.length === 0 transitions correctly.
  • rootMargin tuning600px starts the fetch before the user arrives (perceived-instant); too large prefetches pages nobody reads (bandwidth/API cost). It's a product trade-off with a knob, not a constant.
  • Unmountdestroy() disconnects; a leaked observer keeps callbacks (and everything they close over) alive — the listener-leak discipline again.
  • Back-navigation restore — infinite feeds + back button = lost scroll position unless you cache pages and restore (history's scroll-restoration entry shape); knowing the problem exists is enough at interview pace.

The honest boundary: this isn't virtualization

Infinite loading appends forever — after 5,000 rows the DOM itself is the bottleneck (memory, style/layout cost), no matter how well you fetch. The fix is windowing/virtualization (react-window-style: render only visible rows, recycle the rest — the row-pool world where LRU thinking reappears). Production feeds do both: this controller for data, a virtualizer for DOM. Also name the accessibility cost honestly: infinite scroll breaks "reach the footer," keyboard traversal, and screen-reader landmarks — aria-live="polite" on the status row and a "Load more" button fallback are the mitigations. Volunteering the boundary and the a11y cost is senior-round material.

Common mistakes

  • Scroll-listener + geometry math as the primary answer in 2026.
  • No in-flight guard (duplicate pages), no done state (endless empty fetches), auto-retry on error (storm).
  • Missing the short-page fill case.
  • Client-computed ?page=N pagination presented without the mutation caveat.
  • Conflating infinite loading with virtualization when asked "what breaks at 10,000 rows?"

Follow-up questions

  • "Implement it as a React hook."useInfiniteScroll({ fetchPage }): observer in an effect with cleanup, state in a reducer; the state machine transfers verbatim — which is the point of writing it framework-free first.
  • "Bi-directional scroll (chat history)?" — a second sentinel at the top, plus the hard part: scroll anchoring — prepending content shifts the viewport unless you compensate scrollTop (or use CSS overflow-anchor). Naming overflow-anchor is a deep cut.
  • "Prefetch the next page's images too?"IntersectionObserver again with a larger margin, or <link rel="prefetch"> — same primitive, second use.
  • "How does IntersectionObserver work under the hood?" — computed after layout, delivered as microtask-adjacent callbacks batched per frame — no forced sync layout, which is precisely why it beats scroll handlers.
  • "Test the controller?" — mock IntersectionObserver (jsdom lacks it), drive entries manually, fake timers for debounced variants — assert state transitions, not DOM: idle → loading → idle → … → done, and exactly one fetch per transition (the ordering-contract testing idea).

  • Star Rating Widget

    intermediate

    A dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.

  • Debounce, cancellation, and stale-response guards composed into a search box that never shows the wrong results.

  • A concurrency-limited scheduler with priorities and cancellation — the pool question upgraded to the API-design round.

  • LRU Cache

    advanced

    O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.