Frontend System Design: Infinite Scroll

interviewAugust 24, 2026ยท 8 min read

Infinite scroll looks like a five-line feature: "when the user reaches the bottom, fetch more." That's exactly why interviewers love it. A naive implementation falls apart on the first real-world constraint โ€” a feed that inserts new items while you scroll, a user who opens 200 items and exhausts memory, a keyboard user who can never reach the footer, a flaky network that retries a request twice.

This article walks through the system design using the RADIO framework: Requirements, Architecture, Data Model, Interface, Optimisations.

R โ€” Requirements

Start by clarifying scope with the interviewer. Infinite scroll is a loading strategy, so the requirements are mostly about correctness under real conditions:

Functional:

  • Load the first page on mount (ideally server-rendered), then load more items automatically as the user approaches the end of the list
  • Show loading, error (with retry), and "end of list" states โ€” never a silent dead end
  • No duplicates and no gaps when the underlying data changes between fetches
  • Preserve scroll position when the user navigates away and comes back

Non-functional:

  • 60fps scrolling โ€” the loading mechanism must never jank the main thread
  • Bounded DOM size and memory โ€” a user scrolling for 20 minutes should not accumulate 10,000 nodes
  • Works on slow networks: requests in flight must not stack up or race
  • Accessible: screen-reader users must be told new content loaded; keyboard users must be able to reach everything
  • SEO: first page of content should exist in the initial HTML

Out of scope: ranking, personalisation, or the backend โ€” but you should still design the pagination contract, because it is a frontend concern too.

A โ€” Architecture

Three layers, each independently testable:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  UI layer                                  โ”‚
โ”‚  InfiniteList โ”€ ItemCard[] โ”€ Sentinel      โ”‚
โ”‚  โ”€ ListFooter (loading / error / end)      โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  State layer                               โ”‚
โ”‚  useInfiniteQuery โ€” pagination state       โ”‚
โ”‚  machine: idle โ†’ loading โ†’ success/error   โ”‚
โ”‚  โ†’ loading โ†’ โ€ฆ โ†’ exhausted                 โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Trigger layer                             โ”‚
โ”‚  useIntersectionSentinel โ€” observes a      โ”‚
โ”‚  sentinel div, fires onNearEnd            โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The critical architectural decision is the trigger mechanism. There are three options:

  1. Scroll event listener โ€” fires on every frame while scrolling. You must throttle it, you must read scrollHeight and scrollTop (which can force layout), and it still burns main-thread time. Fine for a prototype, wrong for production.
  2. IntersectionObserver โ€” the browser watches a sentinel element off the main thread and calls your callback only when it crosses the threshold. No per-frame work, no layout reads. This is the standard answer.
  3. CSS content-visibility + scroll anchoring โ€” complements, not replaces, the trigger.

Say this explicitly in the interview: "I'd use IntersectionObserver on a sentinel element with a root margin, so we prefetch before the user actually hits the bottom."

D โ€” Data Model

Cursor pagination, not offsets

The single most important data-model decision. Offset pagination (?page=3) breaks with infinite scroll: if a new item is inserted at the top while you're scrolling, every subsequent offset shifts โ€” you get duplicates or skipped items.

Cursor pagination anchors to the data itself:

{
  "items": [
    { "id": "a91", "title": "โ€ฆ", "createdAt": "2026-08-20T10:00:00Z" }
  ],
  "nextCursor": "MTcwNjE3MDQwMHxhOTE",
  "hasMore": true
}

The cursor encodes the last item's sort key (e.g., timestamp + id). The server returns everything strictly after it, so inserts at the top cannot shift your window.

Client state

{
  pages: [/* Item[][] โ€” one array per fetch */],
  nextCursor: "MTcwNjE3MDQwMHxhOTE" | null,
  status: "idle" | "loading" | "error" | "exhausted",
  error: Error | null,
  isFetchingNextPage: false
}

Keep pages as an array of arrays rather than flattening. You keep page boundaries for retry and trimming, and you flatten at render time (or inside the virtualiser).

Also dedupe by id at the store level โ€” belt and braces against backend cursor drift.

I โ€” Interface

API

GET /api/feed?cursor=<opaque>&limit=20
โ†’ 200 { items: Item[], nextCursor: string | null, hasMore: boolean }

The query hook

function useInfiniteQuery(queryKey, { getNextPageParam, limit }) {
  // returns:
  // {
  //   items,          // flattened
  //   status,
  //   isFetchingNextPage,
  //   fetchNextPage,  // idempotent while a fetch is in flight
  //   retry,          // replays the failed page only
  // }
}

fetchNextPage must be idempotent: calling it while a request is in flight is a no-op. This one rule kills the majority of infinite-scroll bugs.

Components

<InfiniteList
  queryKey={["feed", userId]}
  renderItem={(item) => <ItemCard item={item} />}
  emptyState={<EmptyFeed />}
  estimateItemHeight={180}
  prefetchDistance="400px"
/>

The list renders a Sentinel div after the items, and a ListFooter that reflects state: spinner while loading, retry button on error, "You're all caught up" when exhausted.

O โ€” Optimisations

1. IntersectionObserver with prefetch distance

Don't wait until the sentinel is visible โ€” observe it with a rootMargin so you fetch before the user reaches the end:

function useIntersectionSentinel(onNearEnd, { rootMargin = "400px" }) {
  const ref = useRef(null);
  const callbackRef = useRef(onNearEnd);
  callbackRef.current = onNearEnd;

  useEffect(() => {
    const node = ref.current;
    if (!node) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) callbackRef.current();
      },
      { rootMargin } // fire 400px BEFORE the sentinel is visible
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [rootMargin]);

  return ref;
}

On a fast connection the user never sees a spinner. On a slow one, the footer state covers the gap.

2. Race conditions and duplicate requests

Three real failure modes, three fixes:

  • Double-trigger โ€” the observer fires twice, or React 18 StrictMode mounts twice. Fix: the in-flight guard inside fetchNextPage.
  • Stale response lands late โ€” a slow page 2 resolves after page 3. Fix: tag each request with the cursor it was fetching and ignore mismatches.
  • Unmount mid-flight โ€” the response tries to set state on a dead component. Fix: AbortController with cleanup:
useEffect(() => {
  const controller = new AbortController();
  return () => controller.abort();
}, []);

// pass controller.signal into fetch(...)
// and bail out of state updates if signal.aborted

Mentioning StrictMode double-invocation unprompted is a strong senior signal in interviews.

3. Virtualisation โ€” bounding the DOM

After a few hundred items, layout, paint, and React reconciliation costs make scrolling heavy. Two tiers of fix:

Cheap tier โ€” CSS-only:

.item-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 180px;
}

The browser skips rendering off-screen items while keeping them in the DOM and searchable. Costs almost nothing, huge win for long feeds. If you only remember one thing from this article, make it this property.

Full tier โ€” windowed rendering with react-window or TanStack Virtual: only the ~20 items near the viewport exist in the DOM. Scroll position is decoupled from the DOM and driven by a spacer. This is what the news feed design calls for at scale. The trade-off: variable item heights get tricky, in-page find (Ctrl+F) stops finding off-screen content, and implementation complexity rises.

4. Memory trimming

Even with virtualisation, your data array grows forever. For very long sessions, trim pages far from the viewport and re-fetch them on scroll-up (hard), or cap the retained window (easier โ€” most products do this silently). At minimum, drop large media blobs from items you've scrolled past and keep only metadata.

5. Scroll restoration

User scrolls to item 340, opens a post, hits back โ€” and lands at the top. Browsers' default history.scrollRestoration handles the pixel offset but not your lazily-loaded pages. Two options:

  • Restore the offset, then fast-paginate: fetch pages sequentially until you cover the scroll height, then restore. Simple, slightly slow.
  • Persist cursor + offset: store the last visible item's id; on return, fetch pages starting from that cursor upward. Faster, more complex.

Mobile Safari killing your page and reloading it cold is the same problem โ€” persist the cursor in sessionStorage, not just in-memory state.

6. Accessibility โ€” the part everyone forgets

Infinite scroll has real a11y costs; addressing them unprompted is a differentiator:

  • Screen readers: announce page loads via a polite live region: aria-live="polite" with "Loaded 20 more items". Mark the list aria-busy while fetching.
  • Keyboard users: the footer becomes unreachable as content keeps loading. Provide an explicit "Load more" button as the fallback trigger โ€” this also covers users who disable JavaScript observers, prefers-reduced-motion users, and SEO crawlers.
  • Don't trap focus: appended items must not steal focus or reading position.

7. Progressive enhancement and SEO

Server-render the first page of items; hydrate the infinite behaviour on top. Crawlers get full first-page content, users get fast first paint, and the "Load more" button keeps deep content reachable. If deep items need indexing, expose paginated routes (/feed?page=4) that share the same component.

Interview Gotchas

The failure modes interviewers probe:

  • "What if new items are inserted while scrolling?" โ†’ cursor pagination + dedupe by id.
  • "User scrolls fast, fires five triggers." โ†’ in-flight guard + only one outstanding page request.
  • "How do you test it?" โ†’ mock the API with latency and failures; test error retry, exhaustion, double-trigger, unmount mid-flight. Playwright for real scroll behaviour.
  • "When is infinite scroll the wrong choice?" โ†’ when users need to reach the footer, find items in-page, or maintain a sense of position (e-commerce category pages usually want pagination or a hybrid "load more").

That last answer โ€” knowing when not to use the pattern โ€” is what separates a senior answer from a tutorial answer.

Summary

A production-grade infinite scroll design:

  • Requirements: auto-load near the end, full loading/error/end states, no duplicates, scroll restoration, 60fps, bounded memory, a11y, SSR first page
  • Architecture: InfiniteList + Sentinel + ListFooter; useInfiniteQuery state machine; IntersectionObserver trigger (never a raw scroll listener)
  • Data model: cursor pagination (never offsets), pages stored as Item[][], dedupe by id at the store
  • Interface: GET /api/feed?cursor&limit, idempotent fetchNextPage, page-scoped retry
  • Optimisations: rootMargin prefetch, in-flight + AbortController + cursor-tagged race guards, content-visibility: auto for cheap wins, full virtualisation at scale, memory trimming, sessionStorage cursor persistence, polite live-region announcements and a "Load more" fallback

The Series: Frontend System Design Interview Questions

Donate
ยฉ 2024, Built with Gatsby