Design an infinite scroll list
A reusable list component that loads the next page by itself as the reader nears the end. It looks like one observer and one fetch, but the hard parts are choosing it over pagination at all, firing the load early and exactly once, paging a list that changes under you, bringing people back to the same spot after a back click, keeping the page light after 1,000 items, and making it work for keyboards, screen readers, and search engines.
The question
Our design system needs an infinite scroll list. Teams will use it for search results, activity logs, product grids, and notification lists. It should load more items as the user scrolls, with no page numbers to click.
Design this component. How does it know when to load, how does it page data that changes while you read, what does it show while loading or after an error, and what happens when someone clicks into an item and presses Back? It must stay fast after a long session and work with a keyboard and a screen reader.
Real-life examples: The X and Instagram home timelines, Unsplash and Dribbble image grids, Product search results in shopping apps, Notification and activity lists in web apps
Requirements: what to ask before drawing anything
An infinite scroll list shows the first page of items, then loads the next page by itself when the reader gets near the end. There is no Next button and no page number. To the reader it feels like one long list. To the code it is still a series of pages fetched one after another.
This is a component question, not a product question. You are not designing a social feed with ranking and likes (the news feed guide does that). You are designing the piece a design system ships so that ten teams can drop it into search results, logs, and product grids. That shifts the weight: a clean API, correct behavior on every edge, and good defaults matter more than any one product's data.
Start with questions. Each answer changes something in the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| What kind of content goes in it? | Search results, activity logs, product cards, notifications. Items are 72 to 500 px tall and their heights vary. | No fixed row height, so virtualization later must measure rows. |
| How big do lists get? | A typical session loads 3 to 10 pages of 20. Heavy users scroll 50 pages: 1,000 items. | DOM size and memory become real problems past a few hundred items. |
| Does the list change while you read? | Yes for feeds and logs: new items land at the top every few seconds. Search results are mostly stable. | Offset paging breaks; we need cursors and de-duplication. |
| Does it ever end? | Search results and logs end. Some feeds don't, in practice. | An end-of-list state, and a decision about footers. |
| What happens on Back? | Users open an item, then press Back and expect the same spot. | We must save loaded pages and scroll position ourselves. |
| Does the list scroll the page or its own box? | Both: full-page lists and lists inside a panel or a sidebar. | The trigger must work against the viewport or any scroll container. |
| Does it need to be found by search engines? | Product and search pages do. Logged-in feeds don't. | Real paginated URLs behind the list for crawlers. |
| Which devices and networks? | Half the traffic is mid-range phones on 4G. API answers in 300 ms on Wi-Fi, 1,200 ms or more on weak mobile. | Prefetch distance must cover the slow case. |
Functional requirements
- Render the first page, then load the next page automatically as the reader nears the end, inside the viewport or a scroll container.
- Never show the same item twice and never skip one, even when items are added or removed on the server while the user reads.
- Show clear states: first load (skeletons), loading more, an inline error with Retry, end of list, and empty.
- Return the reader to the same item after Back navigation, without refetching everything.
- Offer a "Load more" button mode, for pages that need a reachable footer and for users who prefer control.
- Accept new items at the top without moving what the reader is looking at.
Non-functional requirements
- No visible wait on a normal connection: the next page should arrive before the reader reaches the end. With a 1,200 ms API and a fast flick of about 3,000 px per second, that means starting the request well ahead of the end.
- Smooth scrolling: no long tasks over 50 ms while appending a page, and no layout shift when items arrive (Cumulative Layout Shift under 0.1).
- Bounded cost: after 1,000 items the page should still scroll at 60 frames per second on a mid-range phone, with memory that does not grow without limit.
- Accessible: usable with a keyboard and a screen reader, with a non-automatic alternative.
- Data-source neutral: works with REST, GraphQL, or local data; the component never knows the URL.
Out of scope
Ranking, likes, and the content of each card belong to the products that use the list. Masonry layout is covered in the Pinterest guide, and a full feed product in the news feed guide. Two-way infinite scroll (loading older items when scrolling up, as a chat does) gets a short note later but is not the main case.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design of this component the most? Select all that apply.
What makes this hard
A first version takes ten minutes: listen for scroll, and when the reader is near the bottom, fetch page two and append it. Almost every later chapter exists because that version breaks in one of five ways. Name them early, since each one drives a decision.
- The trigger is a guess about the future. You must start loading before the reader reaches the end, early enough to hide a 1,200 ms request, but exactly once, and even when the first page is too short to scroll at all.
- The list moves under you. While the reader is on page 3, the server adds 6 new items at the top and deletes 2. A request for "items 40 to 59" now returns a different slice, so the reader sees repeats or never sees some items.
- The browser forgets. The browser restores scroll position on Back by remembering a pixel offset. But the 3,000 px of content the user scrolled through was built by JavaScript after load, so on Back it isn't there yet and the offset points at nothing.
- It grows without limit. Each page adds 20 items: maybe 400 DOM nodes and 7 MB of decoded images (20 thumbnails of 300 by 300 px at 4 bytes per pixel). After 50 pages that is 20,000 nodes and over 300 MB of pixels unless the browser discards some, and style recalculation, layout, and memory all get worse with every page.
- It hides things. The footer moves away every time the reader gets close. Crawlers don't scroll, so items past page one are invisible to search. Keyboard users can't tab past the list, and screen reader users don't know new items arrived.
A strong interview answer touches all five; a weak one stops after the first. The chapters follow the same order, after one question first: should the list scroll forever at all?
Think first
A teammate ships the naive version: a scroll listener that fetches when scrollTop + clientHeight is within 100 px of scrollHeight. Before reading on, list two bugs you expect in production.
First, double loads: a scroll event fires dozens of times per second, so the check passes several times before the first response arrives, and page 2 is fetched three or four times. Second, a visible wait: 100 px is about 0.1 seconds of skimming, so the reader hits the bottom and stares at a spinner for the whole request.
Also likely: on a tall screen where page one doesn't fill the viewport, nothing scrolls, so the check never runs.
Infinite scroll, pagination, or a Load more button
The first decision is whether the component should scroll forever at all. A senior answer doesn't assume it. Infinite scroll is one of three ways to show a long list, and each one fits a different kind of task. A design system should ship the list with at least two of these modes and let each product pick.
Decision How should a long list reveal items past the first page?
What we pick: Match the pattern to the task. For browsing a stream with no goal (a social feed, a photo wall), infinite scroll. For goal-driven lists where people compare, return, and need the footer (search results, product listings, tables of records), pagination or a Load more button. Our component ships infinite scroll and Load more as two modes of the same list, with the same data layer, and supports a hybrid: scroll freely for the first few pages, then show a Load more button.
What research and products have found
Nielsen Norman Group's guidance, which draws on several published studies, makes the same point from the user's side. Infinite scroll suits people who scroll through similar items with no particular goal, as in entertainment, news, and social media. It hurts people who want to find something specific, compare items far apart in the list, or look only at the top few results. They can't say "it was on page 3", and the footer they reach for keeps running away.
NN/g presents a Load more button as a middle ground: it gives back the footer, shows clearly that more content exists, and loads less data up front. The cost is one click per batch, which can make people view less. They also stress that no single pattern wins everywhere; it depends on who the users are and what they are trying to do.
Products have moved both ways. Google added continuous scrolling to its search results on mobile in 2021 and on desktop in 2022, then went back to page links in 2024. It said this lets it serve results faster, and that loading more results automatically didn't make people significantly more satisfied.
Etsy tried infinite scroll on its search results around 2012. In his talk "Design for Continuous Experimentation", engineer Dan McKinley reported that the A/B test showed fewer clicks on results and fewer items favorited, and that people turned to search less often to find items. The team then went back and tested the two assumptions behind the change separately: that people want more results per page, and that they want them faster. The lesson isn't that one pattern is wrong: a list's job decides its loading pattern.
The footer problem
If a page has a footer with contact links, legal pages, or language settings, infinite scroll makes it almost impossible to reach. Every time the reader gets close, a new page loads and pushes it away.
Three fixes work. Move footer content into a sidebar or a menu, as many feed products do. Stop loading by itself after N pages (3 is common) and switch to a Load more button, so the footer appears under the button. Or end the list for real: search results past 1,000 rarely help anyone.
Check your understanding · Exercise 2
Which of these lists are a good fit for pure infinite scroll, with no Load more fallback? Select all that apply.
The trigger: knowing when to load, exactly once
The list must start fetching page N+1 while the reader is still on page N. Load too late and they see a spinner. Load twice and they see duplicates and waste data. Never load and the list silently stops.
This chapter is about that one decision: when does the request go out? Do the arithmetic first. On weak mobile our API takes about 1,200 ms, and a reader skimming a list moves around 1,000 px per second (a fast flick goes much faster for a moment).
To hide a 1,200 ms request from a skimming reader, the request must start at least 1,200 px before the end. A phone viewport is about 800 px tall, so "near the end" really means one and a half to two screens ahead.
Decision How should the list detect that the reader is near the end?
What we pick: Use a sentinel with IntersectionObserver and a rootMargin of about two viewports in the scroll direction. It moves the geometry off your code, it handles scroll containers, and the margin is a direct expression of the time budget. Then fix its two known gaps in code: an in-flight guard against double fires, and a re-check after each load for pages that don't fill the screen.
observe() delivers a first entry: request for page 2 sent. A request is in flight. Press Server answers, or keep scrolling.
Root and rootMargin: viewport or scroll container
The observer measures the sentinel against a root. With root: null the root is the viewport, which is right when the whole page scrolls. When the list scrolls inside its own box (a sidebar, a modal, a panel with overflow: auto), the root must be that box.
The reason is clipping. Before checking against the root, the browser clips the sentinel by every scrolling ancestor in between, and rootMargin does not grow those clips. A sentinel 900 px down inside a 600 px panel is clipped away, so a viewport observer never sees it, however large the margin. A newer option, scrollMargin (Chrome 120, Firefox 141, Safari 26), does grow those inner clips, but passing the panel as the root works in every browser and says what you mean.
rootMargin grows the root's box before the check, with CSS margin syntax: "0px 0px 1500px 0px" adds 1,500 px below. Percentages work too; browsers measure top and bottom percentages against the root's height, so "0px 0px 200% 0px" means two root heights, which adapts to phones and tall monitors. Use the bottom edge for a normal list, the top edge for a list that loads older items upward, like a chat.
One caveat for embeds. With the default viewport root, browsers ignore rootMargin for a target inside a cross-origin iframe, so a list embedded in another site's page would only load once the sentinel is actually visible. Inside an iframe, observe against the frame's own document or its scroll container instead: both are valid roots, and the margin then applies.
The in-flight guard and the short first page
Two rules make the trigger correct. First, keep a loading flag in a ref, not in React state: state updates are applied later, so two callbacks in a row could both read false. If the flag is set, or hasMore is false, or the last load failed, the callback does nothing.
Second, after every page arrives, ask again. The observer only reports changes; if page one is 600 px on a 1,000 px screen, the sentinel never left the range, so no new callback comes and the list stalls with empty space below.
So after each page renders, call unobserve then observe on the sentinel: the browser always delivers a first entry for a newly observed target, with its current state. Do it after the render, because doing it the moment the data arrives measures the old layout.
function useLoadTrigger(opts: {
root: Element | null; // null = viewport
itemCount: number;
hasMore: boolean;
paused: boolean; // true after an error, until Retry
loadMore: () => Promise<void>;
}) {
const sentinel = useRef<HTMLDivElement>(null);
const io = useRef<IntersectionObserver | null>(null);
const loading = useRef(false);
const latest = useRef(opts);
latest.current = opts;
useEffect(() => {
io.current = new IntersectionObserver(async ([entry]) => {
const { hasMore, paused, loadMore } = latest.current;
if (!entry.isIntersecting || loading.current || !hasMore || paused) return;
loading.current = true;
try { await loadMore(); } finally { loading.current = false; }
}, { root: opts.root, rootMargin: "0px 0px 200% 0px" });
if (sentinel.current) io.current.observe(sentinel.current);
return () => io.current?.disconnect();
}, [opts.root]);
useEffect(() => { // after new items render or Retry: ask again
const el = sentinel.current;
if (el && io.current) { io.current.unobserve(el); io.current.observe(el); }
}, [opts.itemCount, opts.paused]);
return sentinel;
}
Add one safety cap. If a buggy API keeps saying hasMore: true while returning empty pages, the re-check loops forever. Count pages that added zero new items; after two in a row, stop and show the end state, and log it so the team that owns the API hears about it.
observe() call after rendering is what keeps a short page from stalling.Check your understanding · Exercise 3
A list inside a 500 px tall side panel with overflow-y: auto never loads page two. The sentinel exists and the observer uses root: null with rootMargin: "0px 0px 2000px 0px". What explains it, and what fixes it? Select all that apply.
Paging a list that changes under you
The trigger decides when to ask. This chapter decides what to ask for, and what to do with the answer. The naive request is GET /items?offset=40&limit=20: skip 40 items, give me the next 20. It is simple and works on a list that never changes, but most lists that use infinite scroll do change.
Walk through it. The reader has loaded items 1 to 40, two pages of 20. While they read, 3 new items are published at the top, so the server's items 41 to 60 are now the old items 38 to 57, and the reader gets 3 repeats at the start of page three.
Turn it around: if 2 items on page one are deleted, offset 40 now starts at the old item 43, and the old items 41 and 42 are never shown. Repeats are annoying; silent gaps are worse, because nobody notices.
Decision How should the list ask the server for the next page?
What we pick: Use opaque cursors. The component treats nextCursor as a string it hands back, and treats null as the end. Then add client de-duplication by id as a safety net, because cursors can't protect against items whose sort key changes, and because retries can deliver a page twice. Offset stays only for the numbered pagination mode, which accepts small shifts because each page replaces the last.
De-duplicating into a normalized store
Keep items in a normalized shape, as the normalization lesson describes: a map from id to item, plus an ordered list of ids for display. Keep the pages too, as a list of { cursor, ids }, because two later features need them: restoring after Back, and dropping far-away pages in long sessions. Merging a page is then a few lines, and a repeat is skipped by checking the set of seen ids.
type ListState<T> = {
byId: Map<string, T>;
order: string[]; // what renders, top to bottom
pages: { cursor: string | null; ids: string[] }[];
nextCursor: string | null; // null means the end
};
function mergePage<T>(s: ListState<T>, page: { items: T[]; nextCursor: string | null; cursor: string | null }, key: (t: T) => string): ListState<T> {
const byId = new Map(s.byId);
const seen = new Set(s.order);
const fresh: string[] = [];
for (const item of page.items) {
const id = key(item);
byId.set(id, item); // newer copy wins, even for repeats
if (!seen.has(id)) { seen.add(id); fresh.push(id); }
}
return {
byId,
order: [...s.order, ...fresh],
pages: [...s.pages, { cursor: page.cursor, ids: fresh }],
nextCursor: page.nextCursor,
};
}
A page made only of repeats adds zero fresh ids. That's the signal the trigger's safety cap counts. Note also that a repeat still updates the stored item: if a post's like count changed between pages, the newer copy wins everywhere it is shown.
When the filter changes mid-load
A product grid has a color filter. The reader is on page 3 of "all colors" with page 4 in flight, and picks "red". The list clears and requests red page 1. Then the old page 4 response arrives, 900 ms late, and the naive code appends 20 non-red products to the red list.
This is the classic request race from the request races lesson, and an infinite list makes it more likely because a load is in flight most of the time while someone scrolls. Three habits prevent it.
- Key the state by query. Store each list under a key built from its inputs, like
products?color=red&sort=new. A response writes only into the key it was requested for, so a late answer lands in the old list, not the new one. Server-state libraries such as TanStack Query do this withuseInfiniteQueryand a query key. - Abort what you no longer need. Pass an
AbortControllersignal tofetchand abort it when the key changes or the component unmounts. That frees the connection for the request that matters. - Reset the trigger. A new key means a new list: clear the in-flight flag, the error, and the empty-page counter, and scroll the list to the top.
abort() stops most late answers, and writing only into the key a request was made for catches the rest.Check your understanding · Exercise 4
A notification list uses ?offset=N&limit=20. Users report seeing the same notification twice while scrolling. Which changes fix the cause or limit the damage? Select all that apply.
Every state the list can be in
A list that only knows "loading" and "done" shows the wrong thing in half the cases: a full-screen spinner when page 7 is loading, a blank area when the filter matches nothing, a spinner forever after a network error. Write the states down as a state machine: a fixed set of states and the events that move between them. Then every screen the reader can see has a name, and every bug report maps to one arrow.
| State | What the reader sees | Details that matter |
|---|---|---|
| Loading first page | Skeleton rows shaped like real items, filling the visible area. | Reserve the real height so nothing jumps when data arrives (keeps CLS low). Set aria-busy="true" on the list. No spinner over an empty page. |
| Ready | Items, and an invisible sentinel after them. | The trigger is armed only in this state. |
| Loading more | Items, then 2 or 3 skeleton rows at the end. | Old items stay fully usable. Skeletons at the end, not a spinner on top, so nothing covers what the reader is reading. |
| Error loading more | Items, then an inline message: "Couldn't load more items." and a Retry button. | The trigger is paused, so it doesn't retry in a loop. Retry moves back to Loading more. |
| Error on first page | A full message with Retry in place of the list. | There is nothing to keep, so the error can take the space. |
| End | A quiet line: "You've seen everything", then the footer. | Disconnect the observer. nextCursor: null is the only signal of the end, not a short page. |
| Empty | "No results for red sneakers" with a way to clear filters. | Different from End: it explains why the list is empty and offers the next step. |
Two details are easy to get wrong. A short page is not the end: the server may have filtered out hidden items. Only nextCursor: null means the end. And Empty is not End: an empty first page should say which filter emptied it.
Decision What should the list do when loading the next page fails?
What we pick: Combine the first two. Retry once by itself after about a second, which covers most blips, then show the inline error with Retry and pause the trigger. Also listen for the online event: when the device comes back online, retry once more without waiting for a tap. Never let a failure look like the end of the list.
Check your understanding · Exercise 5
Which of these behaviors are bugs in an infinite list's states? Select all that apply.
Coming back: scroll restoration after Back
A reader scrolls through 140 products, opens one, then presses Back. They expect to see product 140 again, exactly where it was. What they often get is the top of the list, or a random spot in page two. This is the most common complaint about infinite scroll, and it is a design problem, not a small bug.
Here is why the browser can't fix it alone. By default, history.scrollRestoration is "auto": on Back, the browser remembers the old scroll offset, say 21,000 px, and tries to restore it. But the list on the new page starts empty, or with page one only, about 3,000 px tall.
The browser can't scroll past the end of the content, so it clamps to the bottom of page one, or it scrolls before the app has rendered anything and lands at the top. With a server-rendered page one, the page is reloaded fresh and the other six pages are simply gone.
There is one case where it just works: the back/forward cache (bfcache). When a page navigates to another document, the browser may keep the whole old page frozen in memory, JavaScript state included, and show it again on Back instantly. But a single-page app's internal navigations never use it, and some things, such as unload handlers, make a page ineligible. You can't count on it; treat it as a bonus, and listen for pageshow with event.persisted to know when it happened.
Decision How should the list bring the reader back to the same item after Back?
What we pick: Use the overlay when the product allows it, and always build the anchor save as the general solution: set history.scrollRestoration to "manual", keep pages in a cache, and restore by anchor item, not by pixel offset. A pixel offset is only correct if every item above is the same height as before, which almost never holds on a page with images.
Saving and restoring, step by step
- Where to save.
history.stateis tied to one history entry, which is exactly right: Back to this entry finds its own save. Store a small object there (cursors, anchor id, offset), usinghistory.replaceStateso you don't add entries; some browsers cap the size of a state object and throw past the cap, another reason to keep it small. Keep the items themselves in the server-state cache in memory, or insessionStorageunder the same key if you must survive a reload.sessionStorageis per tab and limited to about 5 MB per origin, so save ids and cursors, not 1,000 full items. - When to save. On every navigation away and on
pagehide. Throttled scroll saves are a backup, since some exits don't give you a chance to run code. - How to pick the anchor. Find the first item whose bottom is below the top of the list's visible area. Save its id and its
getBoundingClientRect().topminus the root's top: for example, itemp_9381at 64 px. - How to restore. Render the saved pages from cache, with no network wait. After the browser lays them out, find the anchor's element and scroll by the difference between where it is and 64 px. Do it again once after images above it finish loading, or rely on scroll anchoring, covered in the next chapter, to hold it in place.
- When to give up. If the save is older than about 30 minutes, or the anchor item no longer exists, start at the top with fresh data. Restoring a stale list is worse than starting over.
type Saved = { key: string; cursors: (string | null)[]; anchorId: string; offset: number; at: number };
type Scroller = HTMLElement | Window;
const viewTop = (s: Scroller) => (s instanceof Window ? 0 : s.getBoundingClientRect().top);
function saveAnchor(list: HTMLElement, scroller: Scroller, key: string, cursors: (string | null)[]) {
const top = viewTop(scroller);
const rows = list.querySelectorAll<HTMLElement>("[data-item-id]");
const first = [...rows].find((r) => r.getBoundingClientRect().bottom > top);
if (!first) return;
const offset = first.getBoundingClientRect().top - top; // may be negative
const saved: Saved = { key, cursors, anchorId: first.dataset.itemId!, offset, at: Date.now() };
history.replaceState({ ...history.state, list: saved }, "");
}
function restoreAnchor(list: HTMLElement, scroller: Scroller, saved: Saved) {
const el = list.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(saved.anchorId)}"]`);
if (!el) return false; // item gone: start at the top
const delta = el.getBoundingClientRect().top - viewTop(scroller) - saved.offset;
scroller.scrollBy({ top: delta, behavior: "instant" });
return true;
}
Check your understanding · Exercise 6
A shop restores the list on Back by saving window.scrollY (18,400 px) and scrolling back to it after rendering the saved pages from cache. Testers say they land two or three products away from where they were. Why? Select all that apply.
Long sessions: DOM size and memory
Every page adds to the document and nothing ever leaves. Take a product card: an image, a title, a price, a rating, two buttons, about 20 elements. Twenty cards per page is 400 elements, and after 50 pages the list holds 20,000.
Style recalculation and layout get slower as the tree grows, and every interaction that touches layout pays for it. Lighthouse's old DOM size audit failed pages with more than about 1,400 elements in the body. Since Lighthouse 13, its "Optimize DOM size" insight flags slow style and layout work instead, but 1,400 is still a handy guideline, and 20,000 is far past it.
Images cost more than elements. A decoded image takes about width times height times 4 bytes, so a 1,080 by 1,080 photo is about 4.7 MB in memory once decoded. Browsers can throw away decoded pixels for images far off screen, but the elements, their listeners, and the item data stay. On a phone with 4 GB of memory shared by everything, a long session can end with the tab being killed and reloaded, which loses the reader's place entirely.
content-visibility: auto cuts the layout and paint work but keeps every element, so find-in-page still works. Virtualization cuts both, at the cost of only the rendered rows being findable.Decision How should the list keep its cost bounded as the reader keeps scrolling?
What we pick: Use tiers, chosen by the product's expected length. Every list gets content-visibility: auto per page by default: it's cheap and keeps everything findable. Lists that routinely pass about 500 items, or have heavy rows, switch on virtualization, which the virtualization lesson covers in depth. On top of either, the store forgets item data for pages more than about 1,000 items away and keeps their cursors, so memory has a ceiling.
Small leaks add up too, as the memory leaks lesson explains: use one IntersectionObserver for the whole list, not one per item, remove listeners on unmount, and abort requests for lists that are gone. Then measure a 10-minute scroll on a mid-range phone and check that the heap and node count level off.
Check your understanding · Exercise 7
A support tool's ticket list reaches 2,000 rows in a normal shift, and agents often use the browser's find-in-page to look for a customer name. Which approach fits best? Select all that apply.
New items arriving at the top
Live lists, like notifications or an activity log, get new items while the reader is further down. If 5 new items arrive while the reader is 40 items in and you insert them at the top, everything below moves down by about 5 item heights, around 600 px. The line the reader was reading jumps off screen. Shifts that don't follow a user action count against Cumulative Layout Shift, and they make people tap the wrong thing.
Browsers have a built-in defense called scroll anchoring. With the CSS property overflow-anchor at its default auto, the browser picks an element in view as an anchor, and if content above it changes size, it adjusts the scroll position so the anchor stays still. Chrome, Edge, and Firefox have done this for years, but Safari added it only in Safari 27 (September 2026), so older iPhones and Macs still need a manual fallback. Anchoring also quietly helps with images above the viewport that finish loading late.
Decision What should the list do when new items arrive above the reader?
What we pick: Use the pill when the reader is scrolled down, and insert directly when they are already at the top, where there is nothing to lose. Keep overflow-anchor: auto on, for late images and other changes above, and turn it off (overflow-anchor: none) on the skeleton rows, so the browser never picks a placeholder as its anchor.
The pill's text, such as "5 new items", should sit in a polite live region, so screen readers announce it once, when they finish speaking. Update the count as more arrive, but don't announce every single item: "5 new" then "12 new" is plenty. The two-way case, a chat that loads older messages when you scroll up, is the same problem in reverse: content is added above the reader on every load, so anchoring (or the manual scroll fix) is required, not optional.
Check your understanding · Exercise 8
A log viewer polls every 5 seconds and prepends new lines. Readers deep in the log complain the text jumps. What are good fixes? Select all that apply.
Keyboard and screen reader users
Infinite scroll was designed around a mouse wheel and a thumb. A keyboard user who presses Tab moves through every link and button in every item; with 20 items per page and new pages arriving, they can tab for minutes and never reach the footer. A screen reader user hears a list with no size and no position, and new items arrive without notice. The fixes are known; they just have to be part of the component, not left to each product.
The WAI-ARIA Authoring Practices describe a feed pattern for exactly this case. The container gets role="feed" and an accessible name. Each item is an article element labelled by its title, carrying aria-posinset (its position, starting at 1) and aria-setsize (the total if known, or -1, the usual case here). Everything inside the feed should be an article, so keep the skeleton rows and the sentinel just after the feed element.
While the list adds or removes articles, it sets aria-busy="true" on the feed, so screen readers wait for the change to finish instead of reading a half-built list. Setting it back to false afterward matters just as much: some screen readers ignore the changes until it does.
aria-busy stops screen readers from reading a half-added page, positions say where an item sits in a list of unknown length, and Control+End is the keyboard way out of an endless list. Only articles go inside the feed.The pattern also recommends keys for when focus is inside the feed. Page Down moves focus to the next article and Page Up to the previous one, so a keyboard user jumps item by item instead of tabbing through every link inside. Control+End moves focus to the first focusable element after the feed, and Control+Home to the first one before it: that's the escape hatch to the footer. Each article needs tabindex="0" so it can take focus.
The pattern is also a contract: the page must load more articles based on which article has focus, not only on scrolling. Moving focus to an article scrolls it into view, which brings the sentinel into range, but add a direct check too: when focus enters one of the last 3 articles, call the same guarded load.
Be careful how much you promise. Screen reader support for the feed role and its reading-mode keys varies by product and version, so test with the screen readers your users have.
- Offer the Load more mode. A real button is the most predictable control for keyboard and screen reader users, and it doesn't depend on how well a screen reader supports feeds. After it loads, move focus to the first new item so the user continues where the new content starts, as the focus management lesson describes.
- Announce, politely and rarely. A visually hidden status region with
aria-live="polite"can say "20 more items loaded" or "End of list". One message per page, never one per item. - Keep focus visible. WCAG 2.2 adds Success Criterion 2.4.11, Focus Not Obscured (Minimum): a focused item must not be completely hidden by your own content. A sticky header or the "new items" pill can cover the focused card. Set
scroll-padding-topto the header's height so focused items scroll into view below it. - Respect reduced motion. When the pill scrolls to the top, use an instant jump if
prefers-reduced-motion: reducematches.
Virtualization adds one more duty. A screen reader only knows about articles that exist in the DOM, so aria-posinset and aria-setsize are what tell it "item 341 of many". Keep them accurate as items mount and unmount. The aria patterns and screen readers lessons go deeper into testing these with VoiceOver and NVDA.
Check your understanding · Exercise 9
Which of these are part of making the list usable without a mouse or without sight? Select all that apply.
Search engines don't scroll
A crawler loads a page, may run its JavaScript, and reads what is there. It doesn't scroll like a person, and it doesn't click a Load more button. So for a product listing with 2,000 products, an infinite list with no other path means search engines see the first 20, and the other 1,980 products are only found if something else links to them.
Google's guidance for infinite scroll, first published in 2014 and repeated in its current lazy-loading and pagination docs, is to back the list with component pages: real URLs that each return one slice, like /shoes?page=2, that work when opened directly, and that are linked to each other with ordinary a href links.
Each page should have its own canonical URL rather than all pointing at page one, show the same items each time it loads, and not overlap with its neighbors. The page number goes in the query string or path, not after a #, because Google ignores fragments.
- Render a real link behind the button. The Load more control is an
aelement withhref="/shoes?page=2". Without JavaScript, or for a crawler, it's a link to page two. With JavaScript, a click handler loads the page in place. The same element serves both. - Server-render each component page. Opening
/shoes?page=5directly shows items 81 to 100, plus links to pages 4 and 6. The infinite list then starts there: it loads 6, 7, 8 downward and offers "Load previous" upward. - Keep the address bar in step. As the reader scrolls into page 5, call
history.replaceStatewith?page=5. A shared or reloaded link then opens near the same spot. UsereplaceState, notpushState, so Back leaves the list instead of stepping through 12 pages. - Cursors and page numbers can coexist. Crawlable pages need stable numbers. The scrolling client can still use cursors; the server maps a page number to a starting point for the first request.
Logged-in lists, like notifications, are never crawled, so the SEO behavior is an option: on for product listings and search pages, off by default.
Check your understanding · Exercise 10
A shop's category page uses infinite scroll, and only the first 24 products of each category appear in search. Which changes help? Select all that apply.
Architecture and data model
Now put the pieces together. The goal of the boundaries is that a product team writes two things, a function that fetches a page and a function that renders an item, and gets everything in the earlier chapters for free. Everything that knows about URLs lives with the product; everything that knows about scrolling lives in the component.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Page fetcher (product code) | Calls the API with a cursor and an AbortSignal, returns { items, nextCursor }. | The only part that knows the URL, REST or GraphQL. Swappable per product and easy to fake in tests. |
| List store | Pages, byId, order, status, error, keyed by query. Merges and de-duplicates. | Pure data logic, testable without a browser. Can be a server-state library's infinite query. |
| Load trigger | One IntersectionObserver on the sentinel, the in-flight guard, re-check after render, focus-based loads. | All timing logic in one place, so the Load more mode just swaps it for a button. |
| List view | Renders items through renderItem, skeletons, error, end, and empty states; feed roles; optional windowing. | Presentation, replaceable without touching data or timing. |
| Restoration | Saves the anchor and cursors on leave; restores after render. | Needs both the store (cursors) and the view (positions), so it sits beside them, not inside either. |
| Live buffer (optional) | Holds new items from polling or a push channel and drives the pill. | Most lists don't need it; it plugs into the store's prepend. |
Data moves in one direction on the way in: the trigger asks the store for the next page, the store calls the fetcher with the saved cursor, the response is merged, and the view renders the new order. Writes, like deleting an item from its card, go through the product's own API and then update the store by id, so every place that shows that item changes at once.
type Status = "loading-first" | "ready" | "loading-more" | "error-first" | "error-more" | "end" | "empty";
type ListEntry<T> = {
key: string; // e.g. "products?color=red&sort=new"
byId: Map<string, T>; // normalized items
order: string[]; // ids in display order
pages: { cursor: string | null; ids: string[]; dropped?: boolean }[];
nextCursor: string | null; // null after the last page
status: Status;
error?: { message: string; retryable: boolean };
pendingTop: string[]; // new ids waiting behind the pill
emptyPagesInARow: number; // safety cap for a broken hasMore
fetchedAt: number; // to expire saved state after ~30 min
};
Where each piece lives matters for restoration. The list entries live in an in-memory cache that outlives the component, so going to an item page and back finds them. The anchor and cursors live in history.state, tied to the history entry. The query itself (filters and sort) lives in the URL, as the URL as state lesson describes, so a shared link rebuilds the same list.
The component API
Ten teams will use this list, so its API is the product. It must work with any data source, let teams own their markup, and make the correct behavior the default. Three shapes are common.
Decision What shape should the component's API take?
What we pick: Ship the headless hook plus a thin component, with a controlled mode. The default path makes de-duplication, races, states, and restoration impossible to forget, while fetchPage keeps the component ignorant of URLs. Teams that already use a server-state library pass its data in controlled mode, as the server-state libraries lesson suggests.
type Page<T> = { items: T[]; nextCursor: string | null };
type InfiniteListProps<T> = {
queryKey: string; // new key = new list
fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;
itemKey: (item: T) => string; // stable id for dedupe and keys
renderItem: (item: T, index: number) => React.ReactNode;
mode?: "auto" | "button" | { autoPages: number }; // hybrid: auto, then button
root?: React.RefObject<HTMLElement | null>; // scroll container; default viewport
prefetch?: string; // rootMargin, default "0px 0px 200% 0px"
restore?: boolean; // save and restore on Back, default true
virtualize?: boolean | { estimateSize: number };
pageHref?: (page: number) => string; // real links for crawlers
renderSkeleton?: () => React.ReactNode;
renderEmpty?: () => React.ReactNode;
renderEnd?: () => React.ReactNode;
label: string; // accessible name of the feed
onError?: (error: unknown) => void; // for logging
};
A few choices in it are deliberate. itemKey is required, because React keys and de-duplication both need a stable id, and index keys break as soon as items are inserted. queryKey is a plain string, so changing a filter is a key change, which resets the list and cancels the old request with no extra code.
label is required, because the feed pattern calls for an accessible name, and a nameless feed is announced only by its role. And mode lets a product opt into the hybrid, such as three automatic pages then a button, without a second component.
Check your understanding · Exercise 11
Which of these props or rules help teams use the list correctly? Select all that apply.
Testing an infinite list
Most of the logic is testable without a real browser: mergePage and the state machine are plain functions, and the trigger depends only on IntersectionObserver. But jsdom, the fake DOM most unit test runners use, has no IntersectionObserver and does no layout. So replace the observer with a fake that records its callback and lets the test say "the sentinel is now in range".
const observers: FakeIO[] = [];
class FakeIO {
targets = new Set<Element>();
constructor(public cb: IntersectionObserverCallback, public options?: IntersectionObserverInit) {
observers.push(this);
}
observe(el: Element) { this.targets.add(el); }
unobserve(el: Element) { this.targets.delete(el); }
disconnect() { this.targets.clear(); }
fire(isIntersecting: boolean) {
const entries = [...this.targets].map((target) => ({ target, isIntersecting }));
this.cb(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver);
}
}
vi.stubGlobal("IntersectionObserver", FakeIO);
test("fires once while a request is in flight", async () => {
const fetchPage = vi.fn()
.mockResolvedValueOnce({ items: page1, nextCursor: "c1" })
.mockReturnValue(new Promise(() => {})); // page 2 never answers
render(<InfiniteList queryKey="q" fetchPage={fetchPage} {...rest} />);
await screen.findByText(page1[0].title);
observers[0].fire(true);
observers[0].fire(true);
expect(fetchPage).toHaveBeenCalledTimes(2); // page 1, then page 2 once
});
With that fake, write one test per rule from the earlier chapters: no second request while one is in flight; a short first page triggers another load after it renders; a late response for an old queryKey is ignored; a repeated id is not rendered twice; nextCursor: null disconnects the observer; an error pauses the trigger until Retry; the feed has aria-busy while loading. Each test names a real failure, so a later refactor can't quietly bring it back.
Some behavior only exists in a real browser: layout, scroll anchoring, content-visibility, and restoration. Cover those with a few end-to-end tests in Playwright or a similar tool: scroll to page 4, open an item, go back, and check that the same item is at the top of the viewport. Run one of them with a slow network profile and a small viewport, where the short-first-page and late-response bugs actually show up.
Running it in the interview
In 45 minutes you can't cover every chapter in depth. Show the whole map, then go deep where the interviewer leans. A plan that works:
| Minutes | What you do | What to say or draw |
|---|---|---|
| 0 to 5 | Ask the scope questions. | Does the list change while reading? Must Back return to the same spot? Does it need SEO? How long do sessions get? Say which answers change the design. |
| 5 to 10 | Choose the pattern. | Infinite scroll for browsing, Load more or pagination for goal-driven lists, and a hybrid mode. Mention the footer. |
| 10 to 20 | Trigger and data. | Draw the sentinel with its rootMargin and the in-flight guard. Show offset failing with 3 inserts, then cursors and de-duplication by id. |
| 20 to 28 | States and the component API. | Draw the state machine. Write the props: fetchPage, itemKey, renderItem, queryKey. |
| 28 to 38 | The deep dive the interviewer picks. | Usually restoration on Back, or memory in long sessions, or accessibility. |
| 38 to 45 | Wrap up. | Testing with a fake observer, what you'd measure, and what you'd do with more time. |
Draw the wireframe and the trigger first: the list, the sentinel, and the margin around the screen. Most of the conversation hangs on that picture. Say the numbers out loud: "1,200 ms on slow mobile, so I start loading about two screens early."
Think first
Follow-up: "Now make it load older items when scrolling up, like a chat." What changes?
The sentinel moves to the top and the margin to the top edge: rootMargin: "2000px 0px 0px 0px". The cursor points backward (before instead of after).
The big change is position: every load adds content above the reader. Without help, the content they're reading jumps down by a page. Rely on scroll anchoring where it's supported and keep a manual fallback: measure the scroll height before inserting and scroll by the difference in the same frame. Restoration anchors and the new-items pill work the same, mirrored.
Think first
Follow-up: "The same component must show a 4-column product grid. What breaks?"
The trigger, paging, and states don't care: the sentinel sits after the grid. Windowing must work by rows of 4, and the row count changes with the width. Restoration anchors on an item whose column may change after a resize. Use page sizes like 24, a multiple of 2, 3, 4, and 6 columns, so a page never ends in a half-empty row.
Check your understanding · Exercise 12
The interviewer says: "Users on old iPhones say the list jumps when they go back to it." Which follow-up steps are reasonable? Select all that apply.
Summary
- Pick the pattern by the task: infinite scroll for browsing streams, pagination or Load more for goal-driven lists, and a hybrid (a few automatic pages, then a button) when the footer matters.
- Trigger with one IntersectionObserver on a sentinel, a rootMargin of about two screens, a ref-based in-flight guard, and a re-check after each render so short pages keep loading. Use the scroll container as the root when the list scrolls inside a box.
- Page with opaque cursors, not offsets, and de-duplicate by id into a normalized store keyed by query, so inserts, retries, and filter changes can't repeat, skip, or mix items.
- Model the states explicitly: first load with skeletons, loading more at the end, inline error with Retry that pauses the trigger, end only when nextCursor is null, and empty as its own state.
- Restore after Back by anchor item, not pixel offset: keep pages in a cache, save the first visible item's id and offset in history.state, and set history.scrollRestoration to manual.
- Bound long sessions: content-visibility: auto by default, virtualization for very long or heavy lists, and forget item data far from the reader while keeping cursors.
- Buffer new items behind a pill when the reader is scrolled down, and keep scroll anchoring on for changes above the viewport.
- Use the feed pattern (role feed, articles with aria-posinset and aria-setsize, aria-busy while loading, Page Down and Control+End keys), offer a Load more mode, and back public lists with real paginated URLs and links for crawlers.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Infinite Scrolling: When to Use It, When to Avoid It (Nielsen Norman Group) Usability research on browsing versus goal-driven lists, footers, and finding items again.
- Intersection Observer API (MDN) Root, rootMargin, thresholds, and how intersections are clipped by ancestors.
- Feed pattern (WAI-ARIA Authoring Practices Guide) Roles, states, and the keyboard contract for infinitely loading content.
- Pagination, incremental page loading, and their impact on Google Search Unique URLs per page, linking, canonicals, and why fragments don't work.
- Infinite scroll search-friendly recommendations (Google Search Central blog) The original component pages advice for infinite scroll.
- Fix lazy-loaded website content: infinite scroll (Google Search Central) Current advice: persistent unique URLs per chunk, sequential links, and updating the URL with the History API.
- Design for Continuous Experimentation (Dan McKinley, Etsy) Talk and slides covering Etsy's infinite scroll test on search results.
- History: scrollRestoration property (MDN)
- Back/forward cache (web.dev) When the browser keeps the whole page for Back, and what blocks it.
- content-visibility: the new CSS property that boosts your rendering performance (web.dev)
- overflow-anchor (MDN) Scroll anchoring and how to opt elements out.
- Understanding Success Criterion 2.4.11: Focus Not Obscured (Minimum) (W3C)
Concepts behind it
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.