Infinite Scroll
Design a feed that loads more content as the user scrolls, using a cursor-paginated API, without leaking DOM nodes, duplicating items, or losing scroll position on back-navigation.
Advertisement
The Problem
Design a feed that loads more content as the user approaches the bottom, indefinitely. No "next page" button, no page numbers - the user scrolls and content keeps arriving.
The naive implementation is roughly ten lines: listen to scroll, compare scrollTop + clientHeight against scrollHeight, fetch the next page when they are close, append the results. That version works in a demo and fails in production for four separate reasons, each of which is the real content of this problem: it costs main-thread time on every scroll event, it duplicates and skips items whenever the underlying data shifts, it grows the DOM without bound, and it loses the user's place the moment they navigate away and come back.
Requirements
Functional
- Load the next page automatically as the user nears the end of the list.
- Show a clear loading indicator for the incoming page, and an explicit end-of-list state.
- Recover from a failed page fetch without discarding what is already loaded.
- Restore both list contents and scroll position when the user returns via back-navigation.
- Never render the same item twice.
Non-functional
- Scrolling stays at the display's refresh rate; no long tasks triggered by scrolling itself.
- One request per page boundary crossed - no duplicate in-flight requests for the same page.
- DOM node count stays bounded, or has an explicit, measured ceiling.
- Correct under concurrent writes: items inserted or deleted server-side while the user reads.
Detecting the Boundary
The trigger question is "has the user scrolled close enough to the end that we should fetch more?" There are two ways to answer it, and they differ in cost, not just in style.
Why the scroll listener is the wrong default
// The pattern to avoid.
function onScroll() {
const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
if (scrollHeight - scrollTop - clientHeight < 600) loadNextPage();
}
window.addEventListener('scroll', onScroll);
Three costs stack up here. First, the handler runs on the main thread for every scroll event the browser dispatches, which on a fast scroll is dozens of times a second. Second, reading scrollHeight and clientHeight forces synchronous layout - the browser must flush any pending style and layout work before it can answer, and it must do so right now, in the middle of a frame it is trying to paint. Third, if the handler is not passive and any code path calls preventDefault, the browser cannot start scrolling until your JavaScript finishes.
You can mitigate all three - { passive: true }, throttle with requestAnimationFrame, cache the measurements - and end up with more code than the alternative that has none of the problems. This is the forced-synchronous-layout trap described in Performance Engineering, and a scrolling feed is where it does the most visible damage: the frame budget is already tight because the browser is compositing, so the handler's cost lands directly on the frames the user is watching.
Intersection Observer
The browser already knows where elements are relative to the viewport. Intersection Observer exposes that knowledge and computes it off the main thread, invoking your callback only when a threshold is crossed.
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
// hasNextPage and isFetching are read from a ref, not from closure state,
// so a stale render does not fire a duplicate request.
if (entry.isIntersecting && stateRef.current.canFetch) {
loadNextPage();
}
},
{
// null root = the viewport. Pass the element for a scrollable container.
root: null,
// Start loading 600px before the sentinel is actually visible.
rootMargin: '0px 0px 600px 0px',
threshold: 0,
},
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadNextPage]);
Instead of thousands of callbacks per scroll session, you get one per page boundary. Three configuration details carry the behaviour:
rootis the scrolling ancestor.nullmeans the viewport. If your feed scrolls inside adivwithoverflow: auto, you must pass that element or the observer will fire at the wrong time - a common bug in modal and panel layouts.rootMargingrows the observed area. A bottom margin of600pxmeans "trigger when the sentinel is 600px below the fold", which is how you hide the request latency: at a typical scroll speed of ~1000px/s, 600px buys about 600ms - roughly one page fetch on a decent connection.threshold: 0fires as soon as any pixel intersects. For a zero-height sentinel this is the only sensible value.
The sentinel
The sentinel is an empty element placed after the last item. When it enters the (margin-expanded) root, more content is needed.
<ul>
{items.map((item) => (
<FeedRow key={item.id} item={item} />
))}
</ul>
{hasNextPage && (
<div ref={sentinelRef} aria-hidden className='h-px' />
)}
<div role='status' aria-live='polite'>
{isFetchingNextPage && 'Loading more posts'}
{!hasNextPage && items.length > 0 && 'End of feed'}
</div>
Two rules. Unmount the sentinel when there is no next page, otherwise it sits permanently inside the root margin at the end of the list and re-fires on every layout change. And keep it out of the accessibility tree - it is a mechanism, not content - while announcing loading and end-of-list through a live region, so a screen reader user is told that more content arrived rather than silently landing in a longer list.
Diagram100%flowchart TB subgraph Container["Scroll container (root)"] direction TB I1["Item 1..20 - page 1"] I2["Item 21..40 - page 2"] I3["Item 41..60 - page 3"] M["rootMargin: 600px below fold<br/>(prefetch zone)"] S["Sentinel - zero height,<br/>aria-hidden, after last item"] end I1 --> I2 --> I3 --> M --> S S -->|"isIntersecting"| G{"canFetch?<br/>hasNextPage AND<br/>not already fetching"} G -->|"no"| X["ignore"] G -->|"yes"| F["fetch(cursor = lastItem.cursor)"] F --> D["dedupe by id<br/>against loaded set"] D --> A["append page,<br/>store nextCursor"] A -->|"hasNextPage false"| U["unmount sentinel,<br/>announce end of feed"] style Container fill:#0f172a,stroke:#334155 style M fill:#1e3a5f,stroke:#3b82f6 style S fill:#1e3a5f,stroke:#3b82f6 style D fill:#1e3f2d,stroke:#22c55e style X fill:#3f1e1e,stroke:#ef4444visualized by
The canFetch gate is not optional. Without it, a fast scroll or a layout shift re-fires the callback while the previous request is still in flight, and you append the same page twice. The guard must read from a ref, not from state captured in the observer's closure - a stale closure is the single most common source of duplicate-page bugs in this design.
Offset vs Cursor Pagination
This is the part of the problem with a genuinely correct answer, and interviewers press on it because it reveals whether a candidate understands that a feed is a moving target.
Offset pagination identifies a page by position: GET /posts?limit=20&offset=40. Position is only meaningful against a fixed ordering, and a live feed is not fixed.
Cursor pagination identifies a page relative to a stable anchor: GET /posts?limit=20&after=eyJ0cyI6MTcy.... The cursor encodes the sort key of the last row you saw - typically a timestamp plus a tiebreaker id - so the next page continues from that row regardless of what changed elsewhere.
Diagram100%flowchart TB subgraph OFF["OFFSET - shifts under inserts"] O1["GET /posts?offset=0&limit=3<br/>-> [P10, P9, P8]"] O2["3 new posts arrive:<br/>P13, P12, P11 inserted at top"] O3["GET /posts?offset=3&limit=3<br/>-> [P10, P9, P8]"] O4["P10, P9, P8 rendered TWICE.<br/>P7, P6, P5 never seen."] O1 --> O2 --> O3 --> O4 end subgraph CUR["CURSOR - anchored to a row"] C1["GET /posts?limit=3<br/>-> [P10, P9, P8]<br/>nextCursor = P8"] C2["3 new posts arrive:<br/>P13, P12, P11 inserted at top"] C3["GET /posts?after=P8&limit=3<br/>-> [P7, P6, P5]"] C4["No duplicates, no gaps.<br/>New posts surfaced separately."] C1 --> C2 --> C3 --> C4 end style OFF fill:#3f1e1e,stroke:#ef4444 style CUR fill:#1e3f2d,stroke:#22c55evisualized by
Walk the failure precisely, because "cursor is better" without the mechanism is not an answer. With ten posts P10 (newest) down to P1, page one at offset 0 returns P10, P9, P8. Three new posts arrive. Every existing row has now shifted down three positions, so offset 3 - which pointed at P7 - points at P10. The user sees P10, P9, P8 for the second time, and when they reach the end, P7, P6 and P5 will have been skipped entirely. Deletions cause the mirror bug: rows shift up, and items are silently skipped rather than duplicated.
Cursor pagination is immune because after=P8 names a row, not a position. Inserting above P8 does not move P8.
The costs, stated honestly:
- No random access. You cannot serve "page 7" from a URL, which rules cursors out for a paginated table with numbered links.
- No total count for free, so "showing 40 of 1,284" needs a separate query.
- The cursor must match the sort order. A cursor encoding
created_atis meaningless if the list is re-sorted by popularity; changing the sort invalidates every cursor and must reset the list. - A tiebreaker is mandatory. With
WHERE created_at < $cursorand two rows sharing a timestamp, one of them is silently dropped. Cursors must encode(created_at, id)and compare on the pair.
For an infinite feed, cursor pagination is the correct default. Offset remains right for numbered pagination over stable, sortable data - an admin table, an archive.
Whichever you choose, the client still needs a duplicate guard. Retries, an aborted-then-resent page, or a genuine server bug will eventually hand you an item you already have:
function appendPage(current: Post[], incoming: Post[]): Post[] {
const seen = new Set(current.map((post) => post.id));
return [...current, ...incoming.filter((post) => !seen.has(post.id))];
}
The Set matters for more than tidiness. Duplicate React keys make rows lose local state and can render in the wrong order, and duplicated rows are one of the most-reported feed bugs precisely because they survive a code review that only reads the happy path.
DOM Growth
Every appended page is permanent. Ten pages of 20 rows, each row a card with an avatar, image, text and three buttons, is 200 rows and easily 8,000-15,000 DOM nodes. Nothing is leaked in the JavaScript sense - it is all still referenced - but the cost is real and compounding:
- Style recalculation and layout scale with node count. Any change that invalidates layout - a hover, a class toggle, a font loading - gets more expensive with every page.
- Memory, particularly on mobile, where decoded images dominate. Fifty full-resolution images held in memory is tens of megabytes and a plausible tab crash.
- Interaction latency. Long tasks triggered by layout on a huge tree are exactly what INP measures, and they are what makes a long feed feel sticky to tap.
Three responses, in increasing order of complexity:
1. Cap it. After N pages, replace the sentinel with a "Load more" button, or hand off to a real paginated URL. This is the simplest fix and often the best product answer, because it also returns the footer to the user.
2. Window it. Render only the visible rows plus a buffer, keeping the scroll container's height correct with spacer elements. This is virtualization, and it composes with infinite scroll rather than replacing it: the observer still triggers pagination, but the rendered node count stays flat no matter how many items are loaded. The mechanics - offset-to-index mapping, variable heights, re-measurement - are the whole subject of Virtualized List.
3. Unmount far-offscreen pages while preserving their measured height. Cheaper to build than full virtualization: keep a page's height in a spacer once it is more than two or three viewports away and drop its rows. content-visibility: auto gets you a weaker version of this for free by letting the browser skip rendering work for offscreen subtrees, without removing the nodes.
Do not virtualize reflexively. A feed of 200 simple rows is fine; 200 rows each containing a video player is not. Measure the actual cost - node count, layout duration in a performance profile, INP in the field - before adding the complexity.
Scroll Restoration
A user scrolls through eight pages, taps a post, reads it, and presses back. They expect to be exactly where they were. By default they land at the top - or, worse, somewhere arbitrary.
The reason is a sequencing problem. The browser's native scroll restoration runs when the document is restored, and at that instant your feed has re-mounted with page one only. The document is not tall enough to scroll to the saved offset, so the browser clamps to what exists. The saved offset was never wrong; there was nothing to scroll.
Restoring correctly means reconstructing enough of the list before restoring position:
type FeedSnapshot = {
cursors: string[]; // pages that were loaded, in order
scrollOffset: number; // fallback if the anchor cannot be found
anchorId: string | null; // id of the topmost visible item
};
// Leaving the feed: capture, keyed by history entry so two tabs of the same
// route do not overwrite each other.
function saveSnapshot(key: string, snapshot: FeedSnapshot) {
sessionStorage.setItem(`feed:${key}`, JSON.stringify(snapshot));
}
// Returning: rehydrate list first, then position.
async function restore(key: string) {
const raw = sessionStorage.getItem(`feed:${key}`);
if (!raw) return;
const snapshot = JSON.parse(raw) as FeedSnapshot;
// Prefer cached pages; only re-request what the cache no longer holds.
await hydratePages(snapshot.cursors);
// Wait for layout to settle before touching scroll, otherwise the offset
// is applied against a document that is still growing.
requestAnimationFrame(() => {
const anchor = snapshot.anchorId
? document.getElementById(`item-${snapshot.anchorId}`)
: null;
if (anchor) {
anchor.scrollIntoView({ block: 'start' });
} else {
window.scrollTo(0, snapshot.scrollOffset);
}
});
}
Two design points do the heavy lifting. Anchoring to an item id beats restoring a pixel offset, because heights are never byte-identical on the second render - an image that was decoded before is now cached and lays out sooner, an ad slot fills differently, a font swaps. Scrolling item abc123 back into view is correct even when the pixel offset has drifted. The pages must come from the client cache, not from eight fresh network requests; re-fetching everything is slow, and with a live feed the pages will not even come back the same. This is the "server state is cached, not owned" position from State Management Architecture, and back-navigation is where a route-level cache earns its keep.
Two supporting details:
// Stop the browser fighting your restoration logic.
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
/* Keep newly loaded content from shoving the reader's position around. */
.feed { overflow-anchor: auto; }
overflow-anchor is the browser's scroll-anchoring feature and it is on by default; the thing to avoid is switching it off globally in a reset. It is what keeps the viewport pinned to the content the user is reading when something above it changes size.
Prepending items is the mirror hazard: inserting at the top shifts everything down and the user loses their place mid-sentence. That is why live feeds show a "new posts" banner instead of injecting silently - a decision examined in Real-Time Feed.
States and Errors
Infinite scroll has more states than "loading" and "loaded", and skipping them produces the failures users notice:
- Initial load - skeleton rows sized like real rows, so nothing shifts when data lands. Sizing skeletons wrongly is a self-inflicted CLS problem, per Performance Engineering.
- Fetching next page - a spinner below existing content. Never replace the list.
- Page error - keep everything loaded, show an inline retry beneath the last item. A failed page five must never discard pages one to four.
- End of list - an explicit terminal message. Without it, users keep scrolling into nothing wondering whether it is broken.
- Empty initial result - a distinct empty state, not an end-of-list marker on a list that never had items.
The retry deserves a guard: after two or three consecutive failures, stop auto-retrying on intersection and require an explicit tap. Otherwise a user parked at the bottom on a flaky connection generates a request storm - the sentinel stays intersecting, so every failure immediately re-triggers a fetch.
Common Interview Follow-Up Questions
"The user has a slow connection and scrolls fast. They hit the bottom before the next page arrives. What do they see?"
Whatever is rendered below the last item, so it must be deliberate: skeleton rows for the incoming page rather than a bare spinner, so the scroll position holds steady and the list keeps its rhythm when real data replaces them. The deeper fix is prefetch distance - raising rootMargin starts the request earlier, and it can be tuned dynamically: measure recent scroll velocity and observed page latency, and expand the margin when the user is scrolling fast enough to outrun the network. There is a limit to how much you should buy on a metered connection, so read navigator.connection.saveData and keep the margin modest when it is set.
"How does this interact with SSR? The first page is server-rendered."
It composes cleanly, and it is the right architecture. Server-render page one so the feed has real content in the initial HTML - good for LCP and for crawlers - then hydrate and let the observer take over for pages two onward. Two requirements: the server must include the nextCursor in the payload so the client does not have to re-fetch page one to learn where to continue, and the client cache must be seeded with the server's page-one data rather than requesting it again on mount. The mechanics and hydration costs are in Rendering Architecture.
"Is an infinitely scrolling feed crawlable and linkable?"
Not by default, and this is its most serious drawback. Content only in pages two-plus is invisible to a crawler that does not scroll, no position in the feed has a URL, and the footer becomes unreachable. Mitigations: server-render the first page, expose real paginated URLs (/feed?after=...) as an alternative path that the infinite view enhances, keep rel=next-style links or a sitemap for the paginated route, and never put anything a user needs - navigation, legal links, settings - in the footer of an infinite page.
"Two requests fire for the same page. How?"
Almost always a stale closure: the observer callback captured isFetching from an earlier render, sees false, and fires again. Read the guard from a ref. The other causes are a sentinel left mounted after hasNextPage became false, so it re-fires on every layout change, and an observer re-created on each render because its effect dependencies change identity, briefly leaving two observers watching the same sentinel. Server-side idempotency on the read is a cheap backstop, but the deduplication-by-id step is what keeps the UI correct regardless.
"When would you use a 'Load more' button instead?" When the user has a goal. Search results, admin tables and anything where a user is looking for a specific item all favour explicit pagination, because deliberate navigation beats endless scrolling and the position stays linkable. A button also solves DOM growth, footer access and accessibility in one move - it is a real control, focusable and announced, whereas an intersection trigger is invisible to assistive technology unless you add a live region. The hybrid is often best: auto-load for two or three pages while engagement is high, then switch to a button.
Tradeoffs Table
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Intersection Observer | Off-main-thread, fires only at boundaries, less code, handles nested scroll roots | Needs a sentinel element and careful lifecycle; must unmount at end of list | Default for every "load when near the end" trigger |
| Scroll event listener | Works anywhere, full control over the geometry | Runs per scroll event, forces synchronous layout, main-thread jank | Only when you need continuous scroll position for something else anyway |
| Cursor pagination | Immune to inserts and deletes, no duplicates or gaps, efficient at depth | No random page access, no free total count, cursor tied to sort order | Live feeds, chronological lists, anything that changes while being read |
| Offset pagination | Random access to any page, trivially linkable, total count is easy | Duplicates on insert, skips on delete, slow at large offsets | Stable datasets with numbered pagination - admin tables, archives |
| Append and keep all rows | Simple, scroll position naturally continuous, no measurement code | Unbounded DOM and memory; layout cost grows with every page | Short feeds with a real cap, or simple rows measured to be cheap |
| Windowed rendering | Flat DOM cost at any list length, stable memory | Meaningful complexity: measurement, restoration, accessibility of offscreen rows | Long-lived feeds, heavy rows, or when a measured DOM ceiling is exceeded |
Where This Applies
Infinite scroll is where the main-thread budget from Performance Engineering becomes tangible: the scroll listener that forces layout, the DOM that grows past the point where style recalculation is free, and the CLS caused by mis-sized skeletons are all the same lesson measured three ways. The server-rendered first page and its handoff to client-side pagination is the hydration boundary from Rendering Architecture, and the cursor-keyed page cache that survives back-navigation is the cache design from Networking and Data Fetching.
It also sets up the rest of this track. When the DOM ceiling is reached, the answer is Virtualized List. When items arrive while the user is reading rather than only when they scroll, the merge problem is Real-Time Feed. And when the rows are images, the loading and layout-stability work moves to Image Gallery with Lazy Loading.
Advertisement
Why is Intersection Observer preferred over a scroll event listener?
Because a scroll listener runs your JavaScript on the main thread for every scroll event, and anything inside it that reads layout - getBoundingClientRect, offsetTop, scrollHeight - forces the browser to recalculate layout synchronously, mid-scroll, at up to 60 times a second. That is the classic recipe for a janky feed. Intersection Observer moves the intersection computation into the browser itself, off the main thread, and calls you back only when a threshold is actually crossed - typically a handful of times over an entire scroll session instead of thousands. It is also less code, because the observer handles the geometry, the root element and the threshold that you would otherwise compute by hand.
Why does offset pagination duplicate and skip items on a live feed, and how does cursor pagination fix it?
Offset pagination asks for rows by position - skip 20, take 20. Position is only meaningful relative to a fixed ordering, and a live feed is not fixed. If three new posts are inserted at the top after you fetch page one, every existing row shifts down three positions, so the rows at offsets 20-39 now include three rows the user already saw on page one, and three rows further down get skipped when the user reaches the end. Cursor pagination asks for rows relative to a stable identity instead - "give me the 20 rows after the row with this id and this timestamp". Inserts and deletes elsewhere in the list do not move that anchor, so the next page continues exactly where the last one ended regardless of what changed above it.
How do you stop DOM size growing without bound as the user scrolls?
You cannot append indefinitely - every row keeps its nodes, styles, event listeners and layout boxes alive, and past a few thousand rows the cost of style recalculation and layout on any change becomes visible as input lag. There are three responses. Cap the total, by switching to a "Load more" button or a paginated URL after N pages, which is the simplest and is often the right product answer. Recycle nodes with windowing, so only the visible rows plus a small buffer exist in the DOM while the scroll container keeps its full height via spacers - this is virtualization, and it composes with infinite scroll rather than replacing it. Or unmount only far-offscreen pages while keeping their measured heights, which is a cheaper partial version of the same idea. Measure before choosing, because the threshold where it starts to matter depends far more on how complex each row is than on the row count.
Why does the browser fail to restore scroll position on a feed after back-navigation, and what fixes it?
Because native scroll restoration runs when the document is restored, and at that moment the feed contains only page one - the other nine pages the user had loaded do not exist yet, so the document is not tall enough to scroll to the saved offset and the browser clamps it to the bottom of what it has. The fix is to persist enough state to reconstruct the list before restoring the offset - the loaded pages or their cursors, the scroll offset, and ideally the id of the topmost visible item. On return, hydrate the list from cache first, then restore the offset - or better, scroll the remembered anchor item back into view, which stays correct even if row heights differ slightly on the second render.
Is infinite scroll ever the wrong choice?
Frequently. It hides the footer, which is a real problem when navigation, legal links or settings live there. It makes any position in the list unlinkable and unbookmarkable, so a user cannot share what they were looking at or return to it. It punishes goal-directed browsing, where a user wants item 400 and now has to load 399 items to reach it, and it makes progress unmeasurable because there is no total. It is a good fit for open-ended, undifferentiated browsing - a social feed, an image wall - and a poor fit for search results, admin tables, or anything a user needs to navigate deliberately. A hybrid is often the honest answer - infinite scroll for a few pages, then a "Load more" button, so the footer remains reachable and DOM growth stops.