cf.completefrontend
My progress
SYSTEM DESIGN · #14

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the infinite scroll list on a search results page for running shoes. From top to bottom: a filters and sort bar whose values live in the URL, item cards drawn by renderItem, a highlighted card that is an article in the feed, skeleton rows while more items load, an empty sentinel element, and the footer, which is only reachable after the end or in Load more mode. Notes on the right: the list has role feed and sets aria-busy while loading; an error shows a message and a Retry button in place of the skeletons; the sentinel is observed with a root margin of two screens; at the end the list says You've seen everything and shows the footer.Search: running shoesfilters and sort, kept in the URLrenderItem(item)article: item 41 of manyskeleton rows: loading moresentinel: an empty divfooter: after the end onlyrole=feed, andaria-busy on loaderror: a messageand a Retry buttonsentinel watched2 screens earlyend: a calm line,then the footer
The component owns everything between the filter bar and the footer. Product code supplies only the card inside each row; the skeletons, the sentinel, the error row, and the end line are the same in every product.

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.

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.

Three ways to show a long list, side by side. Pages: four items, then the links 1, 2, 3, Next, then the footer; easy to return to an item, but a click for every page. Load more: four items, a Load more button, then the footer; the footer stays reachable, but it costs a click per page. Infinite: four items, skeleton rows, and a footer that runs away as new pages load; no clicks at all, but the footer can't be reached.PagesLoad moreInfinite1 2 3 Nextfootereasy to returna click a pageLoad morefooterfooter reachablea click a pagefooter?no clicksfooter runs away
The lists look almost the same. What differs is who decides to load the next page, the reader or the scroll position, and whether anything below the list can ever be reached.

Decision How should a long list reveal items past the first page?

Numbered pagination

Show 20 items and a row of page links: Previous, 1, 2, 3, Next.

Each page has its own URL, like ?page=3, and replaces the list.

Pros

  • Every item has a stable address: "it was on page 4". Easy to come back to, share, and compare.
  • The footer is always reachable, and the page has a clear end.
  • Back works with no extra code, and crawlers follow the links.

Cons

  • A click and a full wait for every 20 items: slow for browsing.
  • Breaks the flow on phones, where page links are small targets.
  • Page 4 means a different thing tomorrow if the list changes.

Load more button

Show 20 items and a button at the end. A click appends the next 20 to the same list.

The URL can still record how many pages are loaded.

Pros

  • The user decides when to load, so the footer stays reachable.
  • Keyboard and screen reader users get a real, focusable control with no surprises.
  • Nothing loads that nobody asked for: less data on metered connections.

Cons

  • One click per page: slower than scrolling for pure browsing.
  • Still needs the same de-duplication, states, and Back restoration as infinite scroll.

Infinite scroll

Load the next page by itself when the reader nears the end of the list.

No clicks, no page numbers.

Pros

  • The lowest effort for browsing: on a phone, a thumb flick is all it takes.
  • Fits content without a natural order to return to, like a stream of posts or photos.

Cons

  • Hard to find an item again, hard to reach the footer, and the end of the list is unclear.
  • Needs extra work for Back navigation, memory, accessibility, and search engines.

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.

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?

Scroll listener

Listen to scroll, throttle it to every 100 ms or so, and compare scrollTop + clientHeight with scrollHeight.

Pros

  • Works in every browser ever made.
  • The distance check is easy to read.

Cons

  • Reads layout on the main thread during scrolling, which can force extra layout work.
  • Fires many times per gesture, so you need your own throttle and guard.
  • Never runs if the content doesn't scroll, so a short first page stalls forever.

Sentinel with IntersectionObserver

Put an empty element (the sentinel) after the last item. An IntersectionObserver with a large rootMargin tells you when it comes within that distance of the visible area.

Pros

  • The browser does the geometry and calls you only when the sentinel crosses the line, not on every scroll event.
  • rootMargin expresses "load 1,500 px ahead" directly.
  • Works the same for the viewport and for a scroll container.

Cons

  • Fires on changes only: if the sentinel stays in range after a page loads, no new callback comes.
  • Asynchronous, so a callback can arrive just after you started a request; you still need a guard.

Observe the Nth-from-last item

Observe item number length - 5 instead of a separate sentinel, and load when it shows.

Pros

  • Distance is measured in items, which matches how people think about "almost done".

Cons

  • Item heights vary from 72 to 500 px, so 5 items can be 360 px or 2,500 px ahead.
  • The observed element changes after every page; easy to leak observers.

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.

Trigger
rootMarginScreen

observe() delivers a first entry: request for page 2 sent. A request is in flight. Press Server answers, or keep scrolling.

A list drawn to scale, 1,000 px per page. 1 of 6 pages are loaded, the screen is 1,400 px tall and scrolled to 0 px, and the root margin adds 1,600 px below it. 1 requests are in flight, 2 were sent in total, and 0 items were repeats. observe() delivers a first entry: request for page 2 sent. A request is in flight. Press Server answers, or keep scrolling.page 1sentinelscreenrootMarginpages: 1 of 6in flight: 1requests: 2repeats: 0 itemsflowing
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.

ts
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;
}
The trigger: one observer, a ref guard, and a re-check after each load.

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.

Loading the next page, as a sequence diagram between the User, the View, the Observer, the Store, and the API. The user scrolls. The Observer tells the Store the sentinel is in range. The Store sets its loading flag and sends GET with after c2 to the API. The Observer fires again, and the Store ignores it because a request is in flight. The API replies with 20 items and the cursor c3. The Store merges them and the View renders. The View re-observes the sentinel, and if it is still in range, the Store loads the next page.UserViewObserverStoreAPIscrollsin rangeGET ?after=c2again: ignored20 items, c3merge, renderre-observein range: load
The guard turns the second callback into a no-op, and the 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.

Paging with 5 items per page, newest first. The reader already has items 20 to 16. Then items 23, 22, and 21 are added, so the server's list is now 23 down to 11. Asking for offset 5 returns items 18, 17, 16, 15, and 14: the first three are repeats. Asking for the items after 16 with a cursor returns 15, 14, 13, 12, and 11, all new.the server's list now: 3 new items on top, then 5 already seen23222120191817161514131211?offset=518171615143 repeats?after=c161514131211all newpage size 5 in this picturethe component uses 20
An offset counts from the top, and the top moved. A cursor names the last item the reader got, so inserts above it change nothing. Deletes above it would make offsets skip items instead.

Decision How should the list ask the server for the next page?

Offset and limit

?offset=40&limit=20. The server skips N rows and returns the next 20.

Pros

  • Trivial on both sides.
  • Can jump to any page, which numbered pagination needs.

Cons

  • Duplicates when items are added before the offset, gaps when items are removed.
  • Databases still walk past the skipped rows, so deep pages get slower.

Cursor (keyset)

Each response includes a nextCursor: an opaque string that encodes the last item's sort key and id. The next request says ?after=<cursor>.

The server returns items that sort after that exact point, wherever it now sits.

Pros

  • Inserts and deletes before the cursor don't shift the next page: no duplicates or gaps from them.
  • Fast at any depth with an index on the sort key.
  • The client never builds the cursor, so the server can change its format freely.

Cons

  • No "jump to page 37".
  • If an item's sort key changes (a score rises), it can still move across the cursor and show twice.

Snapshot token

The first request freezes a view of the results on the server and returns a token; later pages read from that frozen view.

Pros

  • Perfectly stable order for the whole session, even with re-ranking.

Cons

  • Costs server memory per session and expires; stale results for a long session.
  • Rarely available unless your search engine offers it.

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.

ts
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,
  };
}
Merging a page: update every item, append only ids we haven't shown.

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 with useInfiniteQuery and a query key.
  • Abort what you no longer need. Pass an AbortController signal to fetch and 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.
A filter change during a load, as a sequence diagram between the User, the List, the Store, and the API. The Store has asked the API for page 4 of all colors. The user picks red. The List changes the query key to color red. The Store aborts the request for all colors and asks for red page 1. The late answer for all colors page 4 still arrives and is dropped, because its key no longer matches. Red page 1 arrives and the List renders it.UserListStoreAPIGET all, after c3picks redkey: color=redabort all, page 4GET red, page 1old page 4: droppedred page 1render red
Two defenses, because either can miss: 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.

The list's state machine. Loading-first, showing skeletons, goes to ready when page 1 arrives, or to empty when it has 0 items. Ready, with the sentinel armed, goes to loading-more when the sentinel is in range. Loading-more, with skeleton rows at the end, goes back to ready when the page is merged, to end when there is no next cursor, and to error-more when the load fails. Error-more, with the trigger paused, goes back to loading-more on Retry. End turns the observer off. A note says any filter or sort change sends every state back to loading-first, and a failed first page shows a full error with Retry.loading-firstskeletonsreadysentinel armedloading-morerows at the endemptyexplain, offerendobserver offerror-moretrigger pausedpage 1in rangemerged0 itemsno next cursorfailsRetryfilter or sort changes: every state goes back to loading-firstfirst page fails: a full error with Retry
One status value means the list can never show a spinner and an error at once. Only ready arms the trigger, which is what stops retry loops after an error and requests after the end.
StateWhat the reader seesDetails that matter
Loading first pageSkeleton 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.
ReadyItems, and an invisible sentinel after them.The trigger is armed only in this state.
Loading moreItems, 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 moreItems, 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 pageA full message with Retry in place of the list.There is nothing to keep, so the error can take the space.
EndA 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?

Retry in silence

Catch the error and try again with backoff: 1 s, 2 s, 4 s, while showing the loading skeletons.

Pros

  • Hides short blips, like a dropped packet in a tunnel.

Cons

  • A real outage looks like an endless load; the reader can't tell whether to wait or leave.
  • Burns battery and data on a dead connection.

Inline error with Retry

Stop at the end of the loaded items with a short message and a Retry button. Pause the trigger until the reader acts.

Pros

  • Honest: the reader knows what happened and keeps everything already loaded.
  • A real button is reachable by keyboard and announced by screen readers.

Cons

  • One tap to recover from a blip that a retry would have fixed.

Toast notification

Show a temporary toast "Something went wrong" and stay in Ready.

Pros

  • Doesn't change the list's layout.

Cons

  • The toast disappears; the list looks finished, so readers think they reached the end.
  • The next scroll fires the same failing request again.

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.

A failed load, as a sequence diagram between the User, the View, the Store, and the API. The Store sends GET with after c3, and the API answers with a 503 error. The Store waits about a second and retries once by itself; that fails too. The Store moves to error-more, and the View shows an inline message with a Retry button while the trigger stays paused. The user taps Retry. The View asks the Store to retry, the Store sends GET with after c3 again, the API answers with 20 items and cursor c4, and the Store merges them and the View renders them.UserViewStoreAPIGET ?after=c3503 errorretry after 1 sfails againerror-more: pausedtaps RetryretryGET ?after=c320 items, c4merge, render
One automatic retry hides a short blip. After that, the reader sees what happened and decides, and the paused trigger stops scrolling from firing the same failing request again.

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?

Trust the browser

Leave history.scrollRestoration at "auto" and refetch the list on return.

Pros

  • No code.
  • Works fine for lists that render fully on the server.

Cons

  • The offset points into content that doesn't exist yet: the reader lands at the top or at the end of page one.
  • Refetching 7 pages costs 7 requests and seconds of waiting.

Never leave the list

Open the item in an overlay or a side panel that has its own URL, while the list stays mounted underneath.

Pros

  • Position, loaded pages, and images are all still there: nothing to restore.
  • Instant Back.

Cons

  • Only fits items that can show in an overlay.
  • The list keeps its memory while the item is open; a refresh still loses everything.

Save pages and an anchor item

Before leaving, save the loaded page cursors and an anchor: the id of the first visible item and its distance from the top of the list's viewport.

On return, render the saved pages from cache, then scroll so the anchor item sits at the same distance.

Pros

  • Lands on the same item even if heights changed: other images loaded, fonts swapped, the phone rotated.
  • Works for full page navigations and reloads too, when saved in sessionStorage.

Cons

  • Real code: save on leave, restore after render, expire old saves.
  • The cached pages may be minutes old.

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.

On Back
Before leaving, item 47 was at the top of the screen, with 4 pages loaded. After Back with Browser restoration, the screen starts at item 11. That is not item 47. Requests made on return: 1. Only page 1 exists when the browser restores 4,600 px, so it stops at the end of page 1.before you leftitem 47item 48item 49item 50after Backitem 11item 12item 13item 14saved: item 47 on top4 pages in the cachelanded on item 11requests: 1
Only page 1 exists when the browser restores 4,600 px, so it stops at the end of page 1.

Saving and restoring, step by step

  1. Where to save. history.state is 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), using history.replaceState so 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 in sessionStorage under the same key if you must survive a reload. sessionStorage is per tab and limited to about 5 MB per origin, so save ids and cursors, not 1,000 full items.
  2. 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.
  3. 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().top minus the root's top: for example, item p_9381 at 64 px.
  4. 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.
  5. 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.
ts
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;
}
Restoring by anchor: put the saved item back at its saved distance from the top.

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.

Bars for a list of 1,000 items with about 20 elements each. Keep everything: 20,000 elements in the DOM and 20,000 laid out. Content-visibility auto: still 20,000 in the DOM, but only about 1,200 laid out, the pages near the screen. Virtualization: about 600 in the DOM and about 600 laid out. A note says the item data stays in memory in all three unless the store forgets far pages.keep everythingin the DOM20,000laid out20,000content-visibility: autoin the DOM20,000laid outabout 1,200virtualizationin the DOMabout 600laid outabout 600item data stays in memory in all threeunless the store forgets pages far from the reader
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?

Keep everything mounted

Append each page and never remove anything.

Pros

  • Simplest. Find-in-page, screen readers, and anchor links see every item.

Cons

  • Cost grows with every page; long sessions get slow, then crash on low-end phones.

content-visibility: auto

Give each page or item content-visibility: auto and contain-intrinsic-size: auto 480px. The browser skips layout and paint for parts far from the viewport, using the given size as a placeholder.

Pros

  • Two lines of CSS. The elements stay in the DOM, so find-in-page and the accessibility tree still include them.
  • The auto in the size makes the browser remember each part's real height after it was rendered once, so the scrollbar stays honest.

Cons

  • Saves rendering work, not memory: the 20,000 elements still exist.
  • Needs a sensible size guess, or the scrollbar jumps when parts render.
  • Safari 18 to 25 could not find text in skipped parts with find-in-page; Safari 26 fixed it.

Virtualization (windowing)

Render only the items in and near the viewport, about 30, and fill the rest with spacers whose heights come from measured or estimated item sizes.

Pros

  • The DOM stays small and constant: 30 items whether the list has 100 or 10,000.
  • The right tool for very long lists and heavy rows.

Cons

  • Find-in-page and screen readers only see rendered items.
  • Variable heights must be measured, and restoration needs those measurements.

Drop far-away pages

Keep about 10 pages mounted around the reader. Replace pages further away with a spacer of their measured height, and keep only their ids and cursor so they can be refetched.

Pros

  • Bounds both DOM and memory, with page-sized pieces that match how the data arrived.

Cons

  • Scrolling back up refetches, with a short wait.
  • The same find-in-page and screen reader limits as windowing.

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?

Insert right away

Prepend new items as soon as they arrive.

Pros

  • The list is always current.

Cons

  • Content jumps under the reader unless anchoring catches it.
  • Even with anchoring, items appear above where nobody looks, unannounced.

Insert and hold the position

Prepend, then keep the reader still: rely on overflow-anchor, or measure the added height and scroll by it.

Pros

  • No visible jump, and the data is current.

Cons

  • The reader doesn't know new items exist.
  • The manual fallback has to run in the same frame as the insert to avoid a flicker.

Buffer behind a pill

Keep new items in a buffer and show a small "5 new items" pill at the top of the list. A click inserts them and scrolls to the top.

Pros

  • The reader chooses when the list changes: nothing moves while reading.
  • The pill tells everyone, including screen reader users, that something arrived.

Cons

  • One more piece of UI and state.
  • The list is a little stale until the click.

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.

Two phones. Left, insert at the top: five new items are added above, the item the reader was reading slides down out of reach, and their tap lands on a different item. Right, buffer behind a pill: a floating 5 new items pill appears at the top of the list, and the item being read stays exactly where it was.insert at the top5 new itemssome other itemthe tap lands herebuffer behind a pillthe item being read5 new itemsnothing moved
Don't move what someone is reading. Offer the new items, and when the pill is clicked, insert them and scroll to the top.

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.

Markup of the list as a feed. A div with role feed and aria-busy true holds only articles. Item 41 has aria-posinset 41, and item 42 has focus. Page Down moves to the next article. Just after the feed element, outside it, are the skeleton rows while loading and then the empty sentinel. Notes: every article has aria-setsize minus 1 because the total is unknown; a polite status says 20 more loaded; Control+End moves focus past the feed; scroll-padding-top keeps the focused article from hiding under a sticky header.<div role="feed" aria-busy="true">article: item 41aria-posinset="41"article: item 42has focusPage Down: next articleafter the feed:skeleton rowssentinelaria-setsize="-1"total unknownpolite status:20 more loadedControl+End:past the feedscroll-padding-topkeeps focus visible
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-top to 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: reduce matches.

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 a element with href="/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=5 directly 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.replaceState with ?page=5. A shared or reloaded link then opens near the same spot. Use replaceState, not pushState, 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.

The component's architecture. Product code supplies renderItem, which draws a card, and fetchPage, where the URL lives. fetchPage calls the API with a cursor and gets a page of JSON. Inside the component, made of the useInfiniteList hook and the thin InfiniteList: the List store keeps pages and byId, asks fetchPage for the next cursor, and gives the order to the List view, which renders cards through renderItem. The Load trigger, with the observer and the guard, tells the store to load more. Restoration reads cursors from the store and saves the anchor and cursors in history. An optional Live buffer holds new items and prepends them to the store on click.product coderenderItemdraws a cardfetchPageURL lives hereAPI?after=cursorthe componentList viewfeed, statesList storepages, byIdLoad triggerobserver, guardRestorationanchor, cursorsLive buffernew itemshistorystate, sessioncardcursorpage JSONorderload morecursorsanchor, cursorsprepend on click
Only fetchPage knows the URL and only renderItem knows the markup, so the same component serves REST, GraphQL, or local data. Timing, paging, and restoration live inside, written once.
PartResponsibilityWhy 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 storePages, 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 triggerOne 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 viewRenders items through renderItem, skeletons, error, end, and empty states; feed roles; optional windowing.Presentation, replaceable without touching data or timing.
RestorationSaves 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.

Data flow. Top row, right to left for requests: the List view says need page to the List store, the store calls fetchPage with cursor c3, and fetchPage sends GET with after c3 to the API. Left to right for answers: the API returns 20 items and cursor c4, fetchPage hands them to the store, which merges and de-duplicates, and the view gets the new order and status. Second row: the store saves pages to the Query cache and reads them back on Back; the view saves its anchor to history. An item action sends DELETE for item 9 to the API, then updates item 9 in the Query cache by id. A note says page 1 comes as server-rendered HTML for speed and crawlers, then every later page is JSON through fetchPage.APIcursor pagingfetchPagesignalList storeby query keyList viewrendersGET ?after=c3fetchPage(c3)need page20 items, c4merge, dedupeorder, statusItem actiondelete, likeQuery cachein memoryhistoryanchorDELETE /items/9update id 9save pageson Backpage 1: server-rendered HTML, for speed and crawlersevery later page: JSON through fetchPage into the store
Requests flow right to left and data flows back left to right, always through the store, so de-duplication and races are handled in one place. Writes update the store by id, so every copy of an item changes together.
ts
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
};
The client-side model: one entry per query key, pages kept for restoration and trimming.

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?

Self-fetching component

InfiniteList url="/api/items": the component builds requests and parses responses itself.

Pros

  • One line to use for the simplest case.

Cons

  • Locks in one URL format and one response shape; GraphQL or local data don't fit.
  • Can't share a cache with the rest of the app.

Controlled component

The parent passes items, hasMore, status, and a loadMore callback; the component only renders and triggers.

Pros

  • Works with any data source, including a server-state library's infinite query.
  • The parent owns state, so it can prepend, edit, and filter.

Cons

  • Every team re-implements de-duplication, races, and restoration, and some will get it wrong.

Headless hook plus a thin component

A hook, useInfiniteList, owns the store, trigger, and restoration and takes a fetchPage function. A thin InfiniteList renders with it. Teams can use both, or pass their own state in controlled mode.

Pros

  • Correct by default, any data source through fetchPage, full control of markup.
  • The controlled mode stays available for unusual cases.

Cons

  • Two layers to document and version.

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.

tsx
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
};
The props. Product code supplies fetchPage and renderItem; everything else has a default.

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".

tsx
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
});
A fake IntersectionObserver: the test decides when the sentinel intersects.

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:

MinutesWhat you doWhat to say or draw
0 to 5Ask 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 10Choose the pattern.Infinite scroll for browsing, Load more or pagination for goal-driven lists, and a hybrid mode. Mention the footer.
10 to 20Trigger 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 28States and the component API.Draw the state machine. Write the props: fetchPage, itemKey, renderItem, queryKey.
28 to 38The deep dive the interviewer picks.Usually restoration on Back, or memory in long sessions, or accessibility.
38 to 45Wrap 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?

Think first

Follow-up: "The same component must show a 4-column product grid. What breaks?"

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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next