Free lesson · Frontend System Design
Infinite Scroll - Full LLD Case Study
What we're building and the trap to avoid
We're building infinite scroll: a list or feed that loads more items automatically as the user scrolls toward the end, appending each new page so the content appears endless. It's a staple machine-coding prompt and a staple of real products (feeds, search results, galleries), and as an LLD exercise it's valuable because it forces you to think about scroll-driven asynchronous loading - detecting when to fetch, fetching without duplicate or overlapping requests, accumulating pages in order, handling the loading/error/empty/end states, and keeping scrolling smooth. It also sets up a genuine performance problem (the DOM grows without bound) whose solution - virtualization - is the next-next case study, so infinite scroll is the natural bridge from "load more data" to "render large lists efficiently."
The trap here is different from autocomplete's but just as real. The naive infinite scroll attaches a scroll event listener, does some scrollTop math on every scroll tick, and fires a fetch when near the bottom. It "works," and it has three problems an interviewer will probe: the scroll handler fires dozens of times per second and does layout-reading math each time (jank, performance); it has no guard against firing the next fetch while one is already in flight (duplicate/overlapping loads, especially when the user scroll-stops right at the threshold); and it ignores the end condition, the error path, and the empty case. The design below fixes all of these - and, crucially, replaces the scroll-listener approach entirely with the modern, efficient mechanism: the Intersection Observer. Let's design it properly.
Requirements (the clarify phase)
Functional. Items come from a paginated API - I'll use a cursor-based endpoint ({ items, nextCursor }), which is the robust choice for feeds (we cover why in the pagination case studies). When the user nears the end of the list, fetch the next page and append it. Stop when there are no more items. Render each item via a caller-supplied renderer (reusable across feeds).
Non-functional. Scrolling must stay smooth - no per-scroll-tick layout math, so we detect via Intersection Observer, not a scroll listener. We must never fire overlapping or duplicate page loads. We handle loading / error / empty / end-of-list states. We prefetch slightly early (before the user hits the very bottom) so new content is ready by the time they arrive, avoiding a visible stall. We make loading announced for screen readers. And we acknowledge the DOM-growth problem (thousands of nodes) and where virtualization solves it.
Out of scope for the core (mention, defer): full virtualization/windowing (its own case study), bidirectional infinite scroll (loading older and newer), scroll-position restoration on back-navigation (a known infinite-scroll pain point), and pull-to-refresh.
Two detection mechanisms - and why we choose Intersection Observer
The core question of infinite scroll is how do you detect that the user has scrolled near the end? There are two answers, and choosing correctly is itself a signal.
The scroll-listener approach (the old way). Attach a handler to the scroll event and, on each fire, read scrollTop, clientHeight, and scrollHeight to compute whether the user is within some threshold of the bottom, then trigger a load. The problems: the scroll event fires extremely frequently (potentially every animation frame during a scroll), so the handler runs constantly; each run reads layout properties (scrollHeight etc.), which can force synchronous layout and cause jank; and you must throttle it to be tolerable, adding complexity. It works, but it's heavy and fiddly, and it runs on the main thread competing with rendering.
The Intersection Observer approach (the modern, preferred way). Place an invisible sentinel element just after the last item, and use an IntersectionObserver to watch it. The observer fires a callback only when the sentinel enters (or nears) the viewport - i.e., exactly when the user has scrolled to the end. This is dramatically better: the browser computes intersections efficiently and asynchronously (off the main thread, no per-tick handler), there's no scroll math and no throttling to manage, and you get a clean "you've reached the end, load more" signal. The Intersection Observer is purpose-built for "tell me when this element becomes visible," which is precisely the infinite-scroll trigger.
The decision is clear: use Intersection Observer. It's more efficient, simpler, and the idiomatic modern solution. (The scroll-listener-with-throttle approach remains a fallback for environments without IntersectionObserver, but that's increasingly rare.) Naming this tradeoff - "I'd use an Intersection Observer rather than a throttled scroll listener, because it's computed off the main thread and avoids per-scroll layout reads" - is the first senior signal of the problem.
Component architecture
The decomposition isolates the two hard pieces - the data/pagination logic and the intersection detection - into hooks, leaving a thin coordinating container and presentational rendering:
useIntersectionObserver(onIntersect, options)- a reusable hook that returns aref; attach it to the sentinel, and the hook firesonIntersectwhenever that element intersects the viewport (within an optionalrootMarginprefetch zone). It owns the observer's lifecycle. Reusable far beyond this problem (lazy-loading images, reveal-on-scroll, etc.).useInfiniteData({ fetchPage })- the data hook. Owns the accumulateditems, the paginationcursor, thehasMoreflag, and thestatus, and exposes a guardedloadMore()that fetches the next page, appends it, advances the cursor, and prevents overlapping loads. This is where the pagination and concurrency logic lives.InfiniteList- the container. Glues the two hooks together (callsuseInfiniteData, then points the observer at the sentinel withloadMoreas the callback), renders the item list, the sentinel, and the status footer. Thin coordination, no business logic of its own.ItemList/ item rendering - presentational; renders the accumulated items via a caller-suppliedrenderItem.ListFooter- presentational; renders the loading indicator, the error+retry, the empty message, or the end-of-list message, driven offstatus/hasMore/isEmpty.
graph TD
IL["InfiniteList (container)<br/>glues hooks · renders list+sentinel+footer"]
UID["useInfiniteData({fetchPage})<br/>owns: items, cursor, hasMore, status<br/>exposes: loadMore() - GUARDED"] -.->|"items, status, hasMore, loadMore"| IL
UIO["useIntersectionObserver(loadMore, {rootMargin})<br/>owns observer lifecycle"] -.->|"sentinelRef"| IL
IL -->|"items, renderItem"| LIST["ItemList / items<br/>(presentational)"]
IL -->|"attach sentinelRef"| SENT["Sentinel element<br/>(observed marker after last item)"]
IL -->|"status, hasMore, isEmpty, onRetry"| FOOT["ListFooter<br/>loading / error+retry / empty / end"]
SENT -.->|"enters viewport → fires"| UIO
UIO -.->|"onIntersect → loadMore()"| UID
This diagram shows the infinite-scroll architecture. The InfiniteList container glues two hooks together and renders the list, the sentinel, and the footer. The useInfiniteData hook owns the accumulated items, the pagination cursor, the hasMore flag, and the status, and exposes a guarded loadMore function - it passes items, status, hasMore, and loadMore to the container. The useIntersectionObserver hook owns the observer lifecycle and returns a sentinel ref to the container. The container renders the items through an ItemList using a caller-supplied renderItem, attaches the sentinel ref to a sentinel element placed after the last item, and renders a ListFooter driven by status, hasMore, and isEmpty with a retry handler. The loop at the bottom shows the mechanism: when the sentinel enters the viewport it fires the observer, whose onIntersect callback calls loadMore on the data hook, which fetches and appends the next page."
The communication is again data-down/events-up, but with an extra wrinkle worth naming: the trigger flows through a ref and the observer rather than a normal event - the container hands the sentinel's ref (from the observer hook) down to the sentinel element, and the observer hook calls back up into loadMore when intersection happens. So the "event" here is a browser-observed intersection turned into a callback, which is a clean way to decouple "when to load" (the observer) from "how to load" (the data hook).
State design - what state, and where
All stateful logic lives in the two hooks; the container and presentational pieces are stateless. The data hook owns:
items- the accumulated array, appended to on each page load (never replaced). The growing list.cursor- the pagination token for the next page (nullto start). Each fetch uses the current cursor and stores the returnednextCursor. (For an offset/page API this would be apagenumber instead - same shape.)hasMore- whether a next page exists. Derived from whether the API returned anextCursor; whenfalse, we stop loading and show "end of list."status-idle | loading | error | done, driving the footer UI.loadingRef- not React state but a ref used as a synchronous in-flight guard (explained below - this distinction matters for correctness).
The observer hook owns the IntersectionObserver instance (created/disconnected in an effect). Keeping data state in useInfiniteData and observer state in useIntersectionObserver means each hook has one job and both are independently reusable - the observer hook especially, which has nothing to do with pagination.
How the loading loop flows
Trace one cycle, because the guarding is the heart of the design:
sequenceDiagram
participant U as User
participant S as Sentinel
participant O as IntersectionObserver
participant D as useInfiniteData
participant API as API
participant L as List
U->>S: Scroll enters prefetch zone
S->>O: Intersection detected
O->>D: loadMore()
D->>D: Check loadingRef and hasMore
Note right of D: If already loading or no more pages,<br/>return immediately
D->>D: Set loadingRef true
D->>D: Set status to loading
D->>API: Fetch next page using cursor
API-->>D: Return items and next cursor
D->>D: Append new items
D->>D: Update cursor and hasMore
D->>D: Set status to idle or done
D->>D: Reset loadingRef
D-->>L: Re-render with appended items
Note over U,L: User keeps scrolling.<br/>Sentinel enters the viewport again.<br/>Repeat until hasMore becomes false.
This sequence diagram traces one infinite-scroll loading cycle. The user scrolls and the sentinel enters the prefetch zone, producing an intersection that the IntersectionObserver reports by calling loadMore on the data hook. The data hook first guards: if a load is already in flight via the loading ref, or if hasMore is false, it returns immediately and does nothing. Otherwise it sets the loading ref to true and status to loading, then fetches the next page using the current cursor. The API returns the next page of items and a next cursor. The hook appends the new items to the existing list, stores the next cursor, sets hasMore based on whether a next cursor exists, sets status to idle or done accordingly, and clears the loading ref. The list re-renders with the appended items. As the user keeps scrolling, the sentinel re-enters and the cycle repeats until hasMore is false and status is done."
The two guards in that first step - is a load already in flight? and is there more to load? - are what separate a correct infinite scroll from a buggy one, so let's build them in code.
Implementation
The observer hook
useIntersectionObserver encapsulates the observer's setup and teardown and returns a ref to attach to the sentinel. Two details make it correct: it uses a saved-callback ref so the observer isn't torn down and rebuilt every time onIntersect's identity changes (which it does - loadMore changes when the cursor changes), and it accepts an enabled flag so we can stop observing when there's nothing more to load or after an error:
import { useRef, useEffect } from 'react';
function useIntersectionObserver(onIntersect, { rootMargin = '0px', enabled = true } = {}) {
const ref = useRef(null);
const savedCallback = useRef(onIntersect);
// keep the latest callback without re-creating the observer
useEffect(() => { savedCallback.current = onIntersect; }, [onIntersect]);
useEffect(() => {
const node = ref.current;
if (!enabled || !node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) savedCallback.current();
},
{ rootMargin } // e.g. '300px' → fire before the sentinel is fully visible
);
observer.observe(node);
return () => observer.disconnect(); // cleanup on unmount / dep change
}, [enabled, rootMargin]);
return ref;
}
The rootMargin is the prefetch lever: a value like '300px' expands the observer's trigger area below the viewport, so the callback fires when the sentinel is 300px away from entering - i.e., we start loading the next page slightly before the user reaches the bottom, so it's ready by the time they get there. This eliminates the "scroll to bottom, see a stall, then content appears" jank. Tuning rootMargin to prefetch early is a real UX refinement worth mentioning.
The data hook - guarded, ordered page loading
useInfiniteData owns the accumulation and exposes the guarded loadMore. The critical correctness detail is the loadingRef guard:
import { useState, useRef, useCallback } from 'react';
function useInfiniteData({ fetchPage, pageSize = 20 }) {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(null); // null = first page
const [hasMore, setHasMore] = useState(true);
const [status, setStatus] = useState('idle'); // idle|loading|error|done
const loadingRef = useRef(false); // SYNCHRONOUS in-flight guard
const loadMore = useCallback(async () => {
// Guard 1: never overlap loads. Guard 2: stop at the end.
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setStatus('loading');
try {
const { items: page, nextCursor } = await fetchPage({ cursor, pageSize });
setItems((prev) => [...prev, ...page]); // APPEND, never replace
setCursor(nextCursor);
setHasMore(Boolean(nextCursor));
setStatus(nextCursor ? 'idle' : 'done');
} catch (err) {
setStatus('error'); // stop; user can retry
} finally {
loadingRef.current = false;
}
}, [fetchPage, cursor, pageSize, hasMore]);
return { items, status, hasMore, loadMore };
}
Why a ref for the in-flight guard, not the status === 'loading' state? Because state updates are asynchronous and batched - if the observer fires loadMore twice in quick succession (which it can, e.g., a fast scroll or a short initial list where the sentinel is immediately visible), the second call might read a stale status that hasn't updated to 'loading' yet, and fire a duplicate overlapping request. A ref updates synchronously, so the moment the first loadMore sets loadingRef.current = true, any immediate second call sees it and bails. This is the single most important correctness detail in infinite scroll, and using a ref-based guard (not a state flag) is exactly the kind of subtle, real-bug knowledge that signals you've built this for real. Calling it out - "I guard concurrent loads with a ref, not the loading state, because state is async and could let a duplicate request through" - is a strong senior signal.
Note also the concurrency model: because only one page loads at a time (the guard), pages append in order and there's no out-of-order race like autocomplete had - the sequential, guarded loading sidesteps it. (If you wanted to prefetch multiple pages ahead concurrently you'd need ordering logic, but the standard pattern is one-at-a-time, which is simpler and correct.) The error path sets status = 'error' and stops; recovery is an explicit retry.
The container - gluing it together
InfiniteList wires the data hook and the observer hook, kicks off the first load, and renders the list, sentinel, and footer:
import { useEffect } from 'react';
function InfiniteList({ fetchPage, renderItem }) {
const { items, status, hasMore, loadMore } = useInfiniteData({ fetchPage });
// Observe the sentinel; only while there's more and we're not in an error state.
const sentinelRef = useIntersectionObserver(loadMore, {
rootMargin: '300px',
enabled: hasMore && status !== 'error',
});
// Kick off the first page on mount.
useEffect(() => { loadMore(); /* first load */ }, []); // eslint-disable-line
const isEmpty = items.length === 0 && status === 'done';
return (
<div className="feed">
<ul className="feed__list">
{items.map((item) => (
<li key={item.id} className="feed__item">{renderItem(item)}</li>
))}
</ul>
{/* The observed sentinel - placed after the last item. */}
<div ref={sentinelRef} className="feed__sentinel" aria-hidden="true" />
<ListFooter
status={status}
hasMore={hasMore}
isEmpty={isEmpty}
onRetry={loadMore}
/>
</div>
);
}
Two coordination details worth narrating. First, the observer's enabled is hasMore && status !== 'error' - so once we reach the end (hasMore false) or hit an error, the observer stops firing, preventing pointless or runaway loads; on error, loading only resumes when the user clicks Retry (which calls loadMore directly). Second, the initial load is triggered on mount; and notice the guard's other job here - on a short list where the sentinel is visible immediately, the observer may fire loadMore at almost the same moment as the mount call, and the loadingRef guard dedupes them. The guard isn't just for fast scrolling; it's what makes the initial-load-plus-immediate-intersection case correct.
The presentational footer
ListFooter turns the status into the appropriate message - the loading/error/empty/end states all live here, driven off the data:
function ListFooter({ status, hasMore, isEmpty, onRetry }) {
if (isEmpty) {
return <p className="feed__msg">No items to show.</p>;
}
if (status === 'loading') {
return <p className="feed__msg" aria-live="polite">Loading more…</p>;
}
if (status === 'error') {
return (
<div className="feed__msg" role="alert">
Couldn’t load more.{' '}
<button type="button" onClick={onRetry}>Retry</button>
</div>
);
}
if (!hasMore) {
return <p className="feed__msg">You’re all caught up.</p>;
}
return null; // idle with more to load: nothing extra
}
Using it
A parent supplies a stable fetchPage (cursor-based) and a renderItem; the component is reusable across any paginated source:
import { useCallback } from 'react';
function Feed() {
const fetchPage = useCallback(async ({ cursor, pageSize }) => {
const url = `/api/feed?limit=${pageSize}` + (cursor ? `&cursor=${cursor}` : '');
const res = await fetch(url);
if (!res.ok) throw new Error('Network error');
return res.json(); // → { items: [{ id, ... }], nextCursor: string | null }
}, []);
return (
<InfiniteList
fetchPage={fetchPage}
renderItem={(post) => <PostCard post={post} />}
/>
);
}
States and edge cases handled
Walking them explicitly - the differentiator:
- Loading more - the footer shows "Loading more…" with
aria-liveso screen readers hear it;rootMarginprefetch means it's usually invisible because the next page arrives before the user reaches the bottom. - Error - the footer shows a message with
role="alert"and a Retry button; the observer is disabled until retry, so we don't auto-hammer a failing endpoint. - Empty - first page returns nothing → "No items to show," not a blank screen.
- End of list -
hasMorebecomes false → "You're all caught up," and the observer stops; we don't keep firing pointless loads. - Duplicate / overlapping loads - the
loadingRefguard prevents them (the key correctness fix), covering both fast scrolling and the short-initial-list case. - Ordered accumulation - one-at-a-time loading appends pages in order, avoiding the autocomplete-style race.
- Cleanup - the observer disconnects on unmount.
Accessibility
Infinite scroll has real accessibility considerations beyond the loading announcement: the loaded items should be navigable and focusable in order; the loading status should be announced (the aria-live footer); and - a genuine drawback of infinite scroll - content after the feed (a footer, related links) becomes hard or impossible to reach, because scrolling toward it keeps loading more feed. This "footer unreachable" problem is a known infinite-scroll accessibility/UX issue and a reason some products prefer a "Load more" button or pagination for content with an important footer. Mentioning it ("infinite scroll can trap keyboard and screen-reader users away from anything below the feed, so for pages with a meaningful footer I'd consider a 'Load more' button instead") shows you weigh the pattern's costs, not just implement it.
The decision: infinite scroll versus pagination
Infinite scroll is not always the right pattern, and treating it as a choice is the senior framing. It shines for discovery/browsing feeds - social timelines, image galleries, search results you skim - where seamless continuous content suits the behavior. It's poor for content users need to find, return to, or reference by position, because it has no sense of "page 3," makes it hard to return to a spot, breaks the back-button/scroll-restoration expectation (you scroll far, click an item, come back, and you're at the top with the feed reset), and traps the footer. For those cases, explicit pagination (the next case studies) is better. There's also a middle option - a "Load more" button - which keeps the append-pages model but makes loading user-initiated, fixing the footer-trap and back-button problems at the cost of one click per page. The senior answer names the tradeoff: "infinite scroll for discovery feeds; pagination or a load-more button when users need position, findability, or a reachable footer."
The looming problem: DOM growth → virtualization
One more thing to name, because it's the bridge to the next case studies: infinite scroll accumulates DOM nodes without bound. After the user scrolls through thousands of items, there are thousands of DOM elements mounted - even though only a handful are visible - consuming memory and degrading rendering and scroll performance. This is the inherent cost of "keep appending," and the solution is virtualization (windowing): rendering only the items currently in (or near) the viewport and recycling the rest, so the DOM stays small no matter how many items exist. Our InfiniteList renders all accumulated items - fine for hundreds, a problem for tens of thousands - and the fix is to combine infinite loading (this lesson) with virtualized rendering (the pagination/virtualization case study). Calling this out - "this renders every loaded item, which is fine to a point, but past a few thousand I'd virtualize so the DOM only holds the visible window" - shows you see the performance ceiling and its solution.
Extensions
The design absorbs common follow-ups locally:
- "Add a 'Load more' button instead of auto-load" - drop the observer (or keep it disabled) and call
loadMorefrom a button. The data hook is unchanged. - "Show a skeleton while loading the first page" - the
ListFooter/empty branch renders a skeleton (the skeleton case study) for the initialstatus === 'loading' && items.length === 0. - "Bidirectional (load older and newer)" - add a second cursor and a top sentinel; the observer hook is reused for the top, and
loadMoregains a direction. The hooks generalize. - "Virtualize" - swap the plain
<ul>rendering for a virtualized list component that consumes the sameitems; the data and observer hooks are untouched. (Next case study.) - "Deduplicate items" - if the API can return overlapping items across pages, dedupe by id on append in the data hook - one line, localized.
Each lands in one place because the data logic, the detection, and the rendering are cleanly separated.
Important: Infinite scroll loads and appends pages as the user nears the end. Detect via
IntersectionObserverwatching a sentinel element after the last item - not a throttled scroll listener (the observer is off-main-thread, no per-tick layout math). Architecture:useIntersectionObserver(onIntersect, {rootMargin})(returns a ref, owns the observer, uses a saved-callback ref so it isn't rebuilt whenloadMorechanges, and anenabledflag to stop at the end/on error) +useInfiniteData({fetchPage})(ownsitems/cursor/hasMore/status, exposes guardedloadMore) + a thinInfiniteListcontainer + presentational items +ListFooter. The key correctness detail: guard concurrent loads with a synchronous ref (loadingRef), not the loading state - state is async and would let duplicate/overlapping requests through (matters for fast scrolling and the short-initial-list immediate-intersection case). One-at-a-time loading appends in order, sidestepping the race.rootMarginprefetches before the bottom so there's no stall. Handle loading / error+retry / empty / end-of-list, announce loading witharia-live. Decision: infinite scroll suits discovery feeds; pagination or a Load-more button suits content needing position/findability/a reachable footer (infinite scroll traps the footer and breaks scroll restoration). Looming cost: the DOM grows unbounded → virtualize past a few thousand items.
Interview: Lead with the observer choice and the guard: "I'd detect the end with an Intersection Observer on a sentinel after the last item, not a throttled scroll listener, because it runs off the main thread with no layout math. I'd split it into a useIntersectionObserver hook that returns a ref and owns the observer, and a useInfiniteData hook that owns items, cursor, hasMore, and status and exposes a guarded loadMore. The key detail is guarding concurrent loads with a synchronous ref, not the loading state, because state updates are async and would let a duplicate request slip through on fast scroll or when the sentinel is visible immediately on a short list. I'd set rootMargin to prefetch the next page slightly before the bottom, handle loading, error with retry, empty, and end-of-list, disable the observer at the end and on error, and announce loading via aria-live. I'd note that infinite scroll suits discovery feeds but traps the footer and breaks scroll restoration, so for some content pagination or a load-more button is better - and that the DOM grows unbounded, so past a few thousand items I'd virtualize." The observer-over-scroll-listener choice, the ref-based concurrency guard, and the virtualization/decision framing is the senior answer.
What you should carry out of this case study
Infinite scroll loads and appends pages as the user approaches the end, and the right detection mechanism is an Intersection Observer watching a sentinel element after the last item - not a throttled scroll listener - because the observer runs efficiently off the main thread with no per-scroll layout math. The design isolates the two hard pieces into reusable hooks: a useIntersectionObserver that returns a ref and owns the observer lifecycle (with a saved-callback ref so it isn't rebuilt as loadMore changes, and an enabled flag to stop at the end or on error), and a useInfiniteData hook that owns the accumulated items, the pagination cursor, the hasMore flag, and the status, and exposes a guarded loadMore - leaving a thin container and presentational rendering. The single most important correctness detail is guarding concurrent loads with a synchronous ref rather than the asynchronous loading state, which prevents duplicate overlapping requests on fast scrolling and on the short-initial-list case where the sentinel is immediately visible; loading one page at a time also makes pages append in order, sidestepping the out-of-order race. Use rootMargin to prefetch before the bottom, handle the loading, error-with-retry, empty, and end-of-list states with an aria-live loading announcement, and disable the observer once there's nothing more to load. Treat infinite scroll as a decision - it suits discovery feeds but traps the footer and breaks scroll restoration, so pagination or a load-more button is sometimes better - and recognize its inherent DOM-growth cost, whose solution, virtualization, is the next major case study.
The next case study turns from a flat growing list to a recursive structure: Reddit-style nested comments, where comments contain replies that contain replies to arbitrary depth - exercising recursive component design, the tree data model, and where the collapse/reply state lives in a self-referential component hierarchy. Nested comments is next.