cf.completefrontend
My progress
SYSTEM DESIGN · #22

Design a virtualized list

A reusable list that can hold 100,000 rows but only keeps the few dozen near the screen in the page. The idea fits in one line; the hard parts are the offset math, rows whose height is unknown until they render, keeping the reader's place while sizes change and older items load above, fast flings, browser height limits, and what screen readers, keyboards, and find-in-page lose.

The question

Our design system needs one list component for very long lists. Three teams are waiting: chat needs a channel history of up to 50,000 messages that loads older messages above, the CI team needs a log viewer with 200,000 lines, and the admin tool lists 100,000 users.

Design it. How does it decide what to draw, how does it handle rows whose height it can't know in advance, how does it keep the reader's place, and what does it give up? It must scroll smoothly on a mid-range Android phone and work with a keyboard and a screen reader.

Real-life examples: TanStack Virtual, react-window, and react-virtuoso, Slack and Discord message history, Log viewers in CI tools such as GitHub Actions, Admin tables with 100,000 users

Requirements: what to ask before drawing anything

A virtualized list (also called a windowed list) is a scrolling list that pretends to contain every row but only puts the rows near the visible area into the page. Scroll down and rows that leave the top are removed while rows entering at the bottom are created. The reader sees a 100,000 row list with a normal scrollbar. The browser sees about 30 rows.

This is a component question. You are not designing the chat product or the log pipeline. You are designing the piece a design system ships so that three very different teams can use it. That moves the weight toward correct math, good defaults, a small API that covers chat, logs, and tables, and honest answers about what virtualization breaks.

Start with questions. Each answer below changes something in the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
How many rows?Users: 100,000. Logs: 200,000 lines, sometimes 2 million. Chat: up to 50,000 loaded messages.Millions of rows push us toward the browser's maximum element height.
Do rows have a fixed height?Log lines and user rows: yes, 20 px and 48 px. Chat messages: no, 40 px for "ok" and 600 px for a code block with an image.We need a fast fixed path and a measured path for unknown heights.
Is all data in memory?Users and logs: the count is known, rows arrive in pages. Chat: the total is unknown and older pages load above.The list reports its visible range; the page decides what to fetch.
Does it scroll inside a box or with the window?Chat and logs scroll in a panel. The admin list scrolls with the window under a sticky header.The scroll source and an offset for content above the list are inputs.
Which way does it grow?Logs and users grow at the bottom. Chat starts at the bottom and grows in both directions.Reverse mode and prepending without a jump.
Who uses keyboards and screen readers?All three lists must be fully usable with both.Position announcements, focus that survives scrolling, a search for unrendered rows.
Which device sets the bar?A mid-range Android phone with a main thread about 4 to 6 times slower than a laptop's.About 16 ms per frame at 60 Hz for everything, so per-row work must be tiny.
Is it rendered on the server?The admin page is server-rendered; chat and logs are not.The first window must render on the server without measuring anything.
Wireframe of a chat channel built on the virtualized list. Inside a 720 px scroll box, from top to bottom: a loading row while older messages load, a sticky date header reading Yesterday, a message row drawn by renderItem, a taller message with an image that is measured after it renders, another message placed with translateY, and a 3 new messages button at the bottom. Notes on the right: the list has role list with aria-setsize -1 because the total is unknown; the scroll box is 720 px with a spacer holding the total size; about 30 of 50,000 rows are mounted; overscan is half a viewport on each side.#releases: 50,000 messagesloading older messagesYesterday: sticky headerrenderItem(index)message with image:estimated, then measuredtranslateY(start)3 new messagesrole=listaria-setsize=-1scroll box: 720 pxspacer: total size30 of 50,000 rowsmountedoverscan: halfa viewport each side
The component owns the scroll box, the spacer, placement, measuring, and the sticky header. The page supplies only what goes inside each row and decides when to load older messages.

Functional requirements

  • Render lists from 0 to 2,000,000 rows, keeping only the visible rows plus a small buffer in the DOM.
  • Support fixed heights, known per-row heights, and unknown heights that are measured after render, including rows that grow later when an image loads.
  • Keep what the reader is looking at still: when rows above are measured, when rows are prepended, and when the window resizes.
  • Offer scrollToIndex and scrollToOffset, with alignment (start, center, end, auto), even for rows never rendered.
  • Report the visible range so the page can fetch more data, and support a reverse mode for chat that starts at the bottom and follows new messages.
  • Work with vertical lists, horizontal lists, and both axes for grids, with optional sticky group headers.

Non-functional requirements

  • Scrolling holds 60 frames per second on the reference phone: under about 8 ms of script per frame, leaving the rest for style, layout, and paint.
  • DOM size stays flat: under about 1,000 elements inside the list whatever the row count, so memory doesn't grow with the data.
  • First render of a 100,000 row list under 50 ms of script on a laptop, because only the first window is built.
  • No visible jumps: content under the reader's eyes moves at most 1 px when sizes are corrected.
  • Accessible: each row announces its position in the full list ("row 4,801 of 100,000"), focus is never lost when the focused row scrolls away, and every row is reachable by keyboard.
  • Headless core: the math is plain TypeScript with no DOM, testable with numbers alone, and usable from React, Vue, or plain DOM.

Out of scope, and why

  • Fetching and paging data: the infinite scroll guide owns that. The list only reports which indexes are on screen. Keeping fetching out is what makes the component reusable across three teams.
  • Table features such as sorting, column resizing, and cell editing: the data table guide builds those on top of this list, including column virtualization.
  • Drag and drop reordering: the kanban board guide covers dragging inside virtualized columns and the auto-scroll it needs.
  • Masonry layouts, where rows are placed in several columns by height: they need a different layout engine.

Check your understanding · Exercise 1

Which answers from the interviewer change the design of the list itself? Select all that apply.

What makes this hard, and whether you need it at all

Four facts about these lists drive every later decision. They are long: tens of thousands of rows, sometimes millions. They are uneven: rows differ in height and some change height after they appear. They move: rows load above and below while the reader is looking.

Finally, they are read by more than eyes: screen readers, keyboards, and the browser's find-in-page all expect every row to exist in the page. Virtualization fights the last fact on purpose, so it has to pay that back.

What 100,000 real rows cost

Take the admin list. One user row has an avatar, a name, an email, a role badge, and a menu button: about 8 elements with wrappers. 100,000 rows is 800,000 elements. Each element costs memory as a DOM node, again as a computed style and a layout object, and again as a React fiber if React built it.

The numbers below are rough estimates for a laptop, not measurements to quote; the shape is what matters.

Rows renderedElementsFirst render (estimate)What the user feels
100about 800a few msNothing. This is a normal page.
1,000about 8,00050 to 150 msA small hitch on load; fine on a laptop, noticeable on a phone.
10,000about 80,0000.5 to 2 sLong tasks, slow typing in any search box, janky hover effects.
100,000about 800,000several seconds or a crashed tabThe page freezes; phones may kill the tab for memory.
30 (virtualized)about 240under 10 msSame as 100 rows, whatever the total.

Three costs grow with the element count. Creation: the script that builds the nodes, run once but all at once. Memory: it stays for as long as the list is open. Style and layout: any change that touches an ancestor, like toggling a dark mode class or resizing the window, makes the browser recompute styles and layout across the whole tree (the reflow-repaint-composite lesson explains when each step runs).

At 800,000 elements a single class change can take hundreds of milliseconds. Lighthouse's old DOM size audit failed pages with more than about 1,400 elements for this reason. Newer Lighthouse versions look for slow style and layout work instead, but 1,400 is still a handy guideline.

Two ways to show 100,000 user rows. Left, render everything: the scroll box is packed with rows, about 800,000 elements, taking seconds to build and hundreds of megabytes. Right, virtualized: a tall empty spacer of 4,800,000 px holds only the rows near the viewport, about 30 rows and 240 elements, built in under 10 ms.render everythingvirtualizedspacer: 4,800,000 pxabout 30 mounted rows800,000 elements, seconds240 elements, under 10 ms
Both lists have the same scrollbar. On the right, almost all of the 4,800,000 px is one empty spacer; only the band near the viewport holds real rows. Numbers are rough laptop estimates.

Decision How do we show a list with 100,000 rows?

Render everything

Map the whole array to rows.

Let the browser deal with it.

Pros

  • Zero extra code.
  • Find-in-page, screen readers, anchor links, and printing all just work.

Cons

  • Seconds of script on first render and hundreds of MB of memory at 100,000 rows.
  • Every later style change pays for every row.

Pagination or load more

Show 50 rows and a pager, or append pages as the reader scrolls (the infinite scroll guide).

Pros

  • Simple and server friendly.
  • Only loaded rows exist.

Cons

  • Loaded rows still pile up: 50 pages of infinite scroll is 2,500 rows in the DOM.
  • A log viewer needs to jump to line 150,000, which paging makes slow.

content-visibility: auto

Render every row, but give each one content-visibility: auto and a placeholder size with contain-intrinsic-size.

The browser skips layout and paint for rows far from the viewport.

Pros

  • One line of CSS; the rows stay in the DOM, so find-in-page, the accessibility tree, and anchor links keep working.
  • Big wins for long documents with heavy sections.

Cons

  • Every node still exists: creation time and memory are unchanged.
  • React still renders 100,000 components.
  • Scrollbar size depends on the placeholder sizes until rows are seen.

Windowing (virtualization)

Compute which rows intersect the viewport from the scroll position.

Render only those plus a few extras; fake the rest with one tall spacer.

Pros

  • Constant DOM size and memory whatever the count.
  • First render cost is the cost of one screen.

Cons

  • Real complexity: offset math, measurement, scroll anchoring.
  • Find-in-page and screen readers can't see unrendered rows; we must compensate.

What we pick: Use windowing for lists that can pass a few thousand rows, which all three teams have. Keep content-visibility: auto as the recommended cheap tool for long pages with hundreds of heavy sections (articles, settings pages, a comments thread of 300 items), where keeping find-in-page matters more than memory. They are not rivals: windowing bounds the node count, content-visibility cuts the cost of the nodes you keep.

Where content-visibility stops helping

content-visibility: auto is Baseline across current browsers since Safari 18 shipped it in 2024. With contain-intrinsic-size: auto 72px, an off-screen row reserves 72 px until it has been rendered once, and then the browser remembers its real size. That gives a fair scrollbar without any script. One caveat: Safari 18 to 25 could not find text in skipped rows with find-in-page; Safari 26 fixed it.

The catch is in the word skip: the browser skips rendering work for the row, not its existence. The DOM node, its event listeners, its React fiber, and its images all remain. For 300 heavy sections that's fine. For 100,000 rows the creation cost and memory alone sink it.

Think first

A team has a settings page with 120 sections, each a form with 20 inputs. First load is slow. Should they use the virtualized list?

Check your understanding · Exercise 2

Which statements about content-visibility: auto are true? Select all that apply.

The core math: from scrollTop to a range of rows

Everything starts from three numbers the browser gives us. scrollTop is how many pixels of the list have scrolled above the top edge of the scroll box. clientHeight is the height of the visible area, the viewport. And for every row we need its offset: the distance from the top of the list to the top of that row.

The job is to find the rows whose box overlaps the band from scrollTop to scrollTop + clientHeight.

Fixed heights: one division

If every row is h px tall, row i starts at i * h and the math is a division. The first visible index is floor(scrollTop / h) and the last is ceil((scrollTop + clientHeight) / h) - 1, clamped to the row count.

Worked example with the admin list: 100,000 rows of 48 px, a 720 px viewport, scrolled to scrollTop = 96,000. First is floor(96000 / 48) = 2000. Last is ceil(96720 / 48) - 1 = 2014. So 15 rows are visible, the spacer is 100000 * 48 = 4,800,000 px tall, and the work is the same at row 2,000 as at row 99,000.

Variable heights: prefix sums and binary search

When rows differ, keep an array of sizes and a second array of offsets, where offsets[i] is the sum of all sizes before row i. This running total is called a prefix sum. It has count + 1 entries; the last one is the total height of the list.

Offsets only ever grow, so the array is sorted, and a sorted array can be searched with binary search: halve the range on each step. For 100,000 rows that is at most 17 steps to find the first row whose bottom is below scrollTop. Then walk forward, adding sizes, until you pass the bottom of the viewport.

Variable row sizes and their prefix sums. Sizes of rows 0 to 7 are 56, 140, 56, 88, 56, 320, 56, and 72 px. The offsets array has 9 entries: 0, 56, 196, 252, 340, 396, 716, 772, and 844, the last one being the total height. To find the row at scrollTop 400, binary search checks offset 4, which is 340, so it goes right; then offset 6, which is 716, so it goes left; then offset 5, which is 396, the answer. Row 5 is the first visible row, found in 3 steps.sizes561405688563205672offsets012345678056196252340396716772844scrollTop = 400mid 4: 340, go rightmid 6: 716, go leftmid 5: 396, stopfirst visible row: 5
offsets[i] is the sum of every size before row i, so the array is sorted and binary search works. 100,000 rows need at most 17 steps.
ts
// Largest i with offsets[i] <= y: the row that contains pixel y.
function rowAt(offsets: Float64Array, count: number, y: number): number {
  let lo = 0;
  let hi = count - 1;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1;
    if (offsets[mid] <= y) lo = mid;
    else hi = mid - 1;
  }
  return lo;
}

export function getRange(
  offsets: Float64Array, count: number,
  scrollTop: number, viewport: number, overscan: number,
) {
  if (count === 0) return { start: 0, end: -1, first: 0, last: -1 };
  const first = rowAt(offsets, count, scrollTop);
  let last = first;
  const bottom = scrollTop + viewport;
  while (last < count - 1 && offsets[last + 1] < bottom) last++;
  return {
    first, last, // visible rows, for aria and onRangeChange
    start: Math.max(0, first - overscan), // rendered rows
    end: Math.min(count - 1, last + overscan),
  };
}
The heart of the headless core: no DOM, just numbers. offsets has count + 1 entries.

Decision How do we store offsets when row sizes keep changing as they are measured?

Linear scan

Keep only sizes.

On every scroll, add sizes from row 0 until the sum passes scrollTop.

Pros

  • Trivial code, nothing to keep in sync.

Cons

  • O(n) per frame: 100,000 additions at the end of the list on every scroll event.
  • Gets slower the further you scroll.

Prefix array, rebuilt from the lowest changed row

Keep offsets beside sizes.

When row k changes size, mark k dirty; before the next read, recompute offsets from the lowest dirty row to the end.

Pros

  • Reads are O(log n).
  • A rebuild of 100,000 entries is plain additions, well under a millisecond, and happens at most once per frame however many rows were measured.

Cons

  • A size change near row 0 rewrites the whole array.
  • Needs care to batch changes into one rebuild.

Fenwick tree (binary indexed tree)

Store sizes in a tree of partial sums.

Update one size and query any prefix sum in O(log n).

Pros

  • Fast for millions of rows that change size often.
  • No full rebuilds.

Cons

  • Harder to read and debug.
  • Finding the row at a pixel needs a tree descent, not a plain binary search.

What we pick: Use the prefix array with a lazy rebuild from the lowest dirty index. TanStack Virtual works this way, with a binary search over its measurements (react-virtuoso keeps a balanced tree of size ranges instead). It is fast enough for 100,000 rows, and its batching fits the once-per-frame update loop we build later. Mention the Fenwick tree as the upgrade if profiling shows rebuilds of millions of rows on every measurement.

Overscan: rows you render but nobody sees yet

If we render exactly the visible rows, the reader sees blank space at the edge during scrolling. The browser scrolls on its own compositor thread (a separate thread that moves already painted layers), so the content moves before our script has run for that frame. Overscan renders a few extra rows above and below the visible range, so there is something painted to scroll into.

Library defaults are small: TanStack Virtual renders 1 extra row on each side and react-window (version 2) renders 3, while react-virtuoso measures its overscan in pixels. Chat and log viewers usually want more, often 3 to 10 rows or a pixel budget such as half a viewport. A pixel budget is the better unit when rows range from 20 px to 600 px.

  • More overscan means fewer blank flashes but more rows to render each time the range shifts, and a larger DOM.
  • Directional overscan puts most of the extra rows on the side the reader is scrolling toward: 8 below and 2 above while scrolling down.
  • Re-render only when start or end changes. Scrolling 10 px inside the same rows must not re-render the list.
HeightsOverscan
A list of 100,000 fixed 48 px rows, 4,800,000 px tall, scrolled to 96,000. Visible rows 2,000 to 2,014; with overscan 3, rows 1,997 to 2,017 are mounted: 21 rows, about 168 elements. 1 renders for 0 scroll updates.all rowsmounted rows, zoomed invisible rows:2,000 to 2,014mounted rows:1,997 to 2,01721 rows mountedabout 168 elementsnot 800,000renders: 1scroll updates: 0total height: 4,800,000 px
Drag the slider or use the buttons. The list renders again only when the mounted range changes.

Check your understanding · Exercise 3

Fixed rows of 20 px (a log viewer), 2,000 rows, viewport 400 px, scrollTop = 1,010, overscan 3. Which statements are true? Select all that apply.

The DOM structure: a tall spacer and a few placed rows

The math gives a range and an offset per row. Now the browser has to believe the list is 4,800,000 px tall so the scrollbar thumb has the right size and dragging it to the middle lands on row 50,000. Three elements do it: the scroll box with a fixed height and overflow: auto, a spacer inside it whose height is the total size of all rows, and the rendered rows placed at their true offsets inside the spacer.

The DOM of a virtualized list. A tall dashed spacer whose height is the total size of all rows. A scroll box of 720 px with overflow auto shows only a band of it. Six rows are placed inside the spacer with position absolute and translateY of their start offset: four inside the visible band, and one overscan row just above and one just below it.div.spacerheight: totalSizediv.scroll: 720 pxoverflow: autorow: position absolutetranslateY(start)overscan rowsabove and below
The spacer gives the scrollbar its size; the rows are the only real content. The overscan rows sit just outside the visible band, ready for the next frame.

Decision How do we place the rendered rows at their true offsets?

Absolute rows with translateY

Spacer has position: relative and the total height.

Each row has position: absolute; top: 0 and transform: translateY(offset).

Pros

  • Each row is independent: re-measuring one doesn't reflow its siblings.
  • The same model works for horizontal lists and grids.
  • Moving a row with a transform skips layout.

Cons

  • Rows are out of normal flow, so margins don't collapse and the list must set widths itself.
  • With estimated offsets, a row that measures taller overlaps the next one until the correction lands.

Padding above and below

Rows stay in normal flow.

A top padding equal to offsets[start] and a bottom padding for everything after end fake the missing rows.

Pros

  • Rows lay out like any list: margins, flex, and real table rows work.
  • A row that grows pushes the next ones down at once, with no overlap.

Cons

  • Every range change rewrites the padding and lays out all rendered rows.
  • A size change above the viewport shifts content unless we correct scrollTop.

One translated wrapper with rows in flow

Spacer holds the total height.

A single wrapper is moved with translateY(offsets[start]) and the rows flow inside it.

Pros

  • Rows in flow, but only one element moves when the range changes.
  • Works well for table markup, where the wrapper can be the tbody.

Cons

  • Measuring a row still needs each row's own box.
  • Sticky elements inside the wrapper only stick within the wrapper.

What we pick: Default to absolute rows with translateY: it is what TanStack Virtual's examples use, it keeps rows independent, and it extends to the grid case. Offer padding or a translated wrapper as a mode for semantic tables and lists that need normal flow. Because corrections run in a ResizeObserver callback, which fires after layout but before paint, the overlap in the first option is never painted when measurement is wired correctly.

tsx
<div ref={scrollRef} style={{ height: 720, overflow: "auto", overflowAnchor: "none" }}>
  <div style={{ height: totalSize, position: "relative" }}>
    {items.map((item) => (
      <div
        key={item.key}
        data-index={item.index}
        ref={measureRow}
        style={{
          position: "absolute", top: 0, left: 0, width: "100%",
          transform: `translateY(${item.start}px)`,
        }}
      >
        {renderItem(item.index)}
      </div>
    ))}
  </div>
</div>
The rendered tree for the default mode. Keys are row ids, not indexes.

overflowAnchor: "none" turns off the browser's own scroll anchoring for the box. We do our own anchoring in the next chapter, and two systems correcting the same scroll position at once produce a double jump.

Sticky group headers inside a virtualized list

Chat shows a sticky date header ("Yesterday") and the admin list groups users by team. position: sticky keeps an element pinned while its containing block (its parent's box) is on screen. If the header is one absolutely positioned row among others, its parent is the spacer, so it would stick for the whole list, and as soon as it scrolls out of the rendered range it is unmounted and vanishes. Two changes fix it:

  1. Treat headers as rows of their own in the item array, so the math places them like any row.
  2. Always keep the active header mounted: when computing the range, add the index of the last header at or above first, even if it is outside the overscan. Render that one header with position: sticky; top: 0 and a z-index above rows, and the others normally.
ts
function withStickyHeader(range: Range, headerIndexes: number[]): number[] {
  // last header at or before the first visible row
  let lo = 0, hi = headerIndexes.length - 1, active = -1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (headerIndexes[mid] <= range.first) { active = headerIndexes[mid]; lo = mid + 1; }
    else hi = mid - 1;
  }
  const rows: number[] = [];
  if (active !== -1 && active < range.start) rows.push(active);
  for (let i = range.start; i <= range.end; i++) rows.push(i);
  return rows;
}
A range extractor that pins the active group header. headerIndexes is sorted.

Check your understanding · Exercise 4

Why does the spacer exist, instead of just rendering the visible rows in a short box? Select all that apply.

Unknown heights: estimate, measure, correct, and don't jump

A chat message's height depends on its text length, the font, the container width, whether it has an image, and whether the image has loaded. We can't know it before the browser lays it out, but the math needs every row's size to compute offsets and the total height. The answer has three steps: estimate every row, measure the ones that render, and correct the offsets without moving what the reader is looking at.

Step 1: estimate

estimateSize(index) returns a guess for rows not yet measured. A constant (56 px for every message) works, but the closer the guess, the smaller the later corrections. Good estimates use what we know without rendering: 56 px for a text message, 56 plus the image's aspect-ratio height for an image message (the server sends width and height), 24 px for a date header. Some lists use the running average of measured rows for unmeasured ones, which makes the scrollbar settle faster on long sessions.

Step 2: measure with ResizeObserver

When a row mounts, observe it with one shared ResizeObserver. Its callback receives the row's borderBoxSize, and it fires again whenever the row changes size later: an image loads, a web font swaps in, the reader expands a stack trace in the log viewer. Reading offsetHeight once in a layout effect would catch the first size only. ResizeObserver callbacks run after layout and before paint, so a correction made inside the callback appears in the same frame the reader would have seen the wrong layout.

Store measurements by item key, not by index. In chat, prepending 50 older messages shifts every index by 50. A cache keyed by index would hand message A's height to message B. Keyed by message id, the cache stays correct and the offsets are rebuilt from it.

Step 3: correct offsets and keep the reader's place

Here is the bug every first version has. The reader scrolls up into rows that were estimated at 56 px. Row 4,000 renders and measures 140 px. Every offset below row 4,000 grows by 84 px, including the rows on screen.

The browser keeps scrollTop where it was, so the content the reader was looking at slides down 84 px. Scroll up through 20 such rows and the text jumps around like a broken video.

The fix is scroll anchoring: pick an anchor row, the first visible row, and remember how far its top is from the top of the viewport. After applying measurements and rebuilding offsets, set scrollTop so the anchor row's top is at the same distance again. Changes below the anchor move nothing on screen. Changes above it are absorbed by moving scrollTop by the same amount, which the reader can't see because the content under their eyes stays put.

ts
const ro = new ResizeObserver((entries) => {
  const el = scrollEl;
  const anchor = core.rowAt(el.scrollTop);           // first visible row
  const anchorGap = core.offsetOf(anchor) - el.scrollTop;
  let changed = false;
  for (const entry of entries) {
    const index = Number((entry.target as HTMLElement).dataset.index);
    const size = entry.borderBoxSize[0].blockSize;
    if (core.setSize(index, size)) changed = true;    // false if unchanged
  }
  if (!changed) return;
  core.rebuildOffsets();                              // from lowest dirty row
  const target = core.offsetOf(anchor) - anchorGap;
  if (Math.abs(target - el.scrollTop) >= 0.5) el.scrollTop = target;
  scheduleRender();                                   // new total size, new range
});
One correction per ResizeObserver callback, however many rows changed.
Sequence of measuring a row above the reader, between Row, ResizeObserver, Core, and the scroll box. The row mounts at its estimate of 56 px and is observed. After layout, the observer reports a size of 140 px to the core. The core reads the anchor row and its gap from the scroll box, then raises scrollTop by 84 px. The rows re-render at their new offsets and nothing on screen moves. All of it happens before paint.RowResizeObserverCoreScroll boxobserve (56 px)size: 140 pxread anchor gapscrollTop += 84no visible jumpall before the frame paints
The callback runs after layout and before paint, so the reader never sees the row at its estimated size above them. The 84 px growth is absorbed by moving scrollTop, not by moving the content.

Decision Who keeps the reader's place when rows above change size?

Nobody

Apply new sizes and let the content shift.

Pros

  • No code.

Cons

  • Visible jumps whenever the reader scrolls up into unmeasured rows, the most common complaint about naive virtual lists.

The browser's CSS scroll anchoring

Leave overflow-anchor: auto on and let the browser pick an anchor node and adjust the scroll.

Pros

  • No script.
  • Works well for normal-flow content such as articles with late images.

Cons

  • The browser doesn't know our offsets; it only sees the few mounted rows and may pick a node that is about to unmount. Per the spec, anchoring also stands down when transform or height changes on the anchor or an ancestor up to the scroller, which is exactly how our rows and spacer change.
  • Chrome and Firefox have supported it for years, but Safari added it only in Safari 27 (September 2026), so older iPhones and Macs get no help.

The list anchors itself

Turn off native anchoring with overflow-anchor: none.

Record the anchor row and its gap before each correction, restore it after.

Pros

  • Same behavior in every browser.
  • Also covers prepends, width changes, and scrollToIndex corrections with the same code.

Cons

  • Must be careful during a fling: writing scrollTop while the user is scrolling with momentum can stop the momentum on some touch devices.

What we pick: The list anchors itself. It is the only option that also handles prepending and resizes the same way. For the fling problem, follow what libraries do: while the user is actively scrolling up, apply corrections for rows above the viewport in one batch when scrolling pauses, or adjust only rows that enter the viewport from above.

A chat of 14 rows, 974 px tall, with 10 rows still estimated at 56 px. scrollTop is 674; the anchor is row 11, -62 px from the top of the 300 px view. Scroll anchoring is on. Nothing has changed yet.whole listthe 300 px viewrow 11: 180 pxrow 12: 72 pxrow 13: 110 pxscrollTop: 674total: 974 pxestimated: 10anchor: row 11its top at -62 pxcontent moved:nothing yethatched: estimated, not measured yet
You're at the bottom of a chat. Rows above are estimated at 56 px. Scroll up and watch them get measured.

Images that load late, and width changes

  • Reserve image space before it loads: width and height attributes or aspect-ratio from server metadata. Then the first measurement is already correct and the late load changes nothing.
  • When metadata is missing, ResizeObserver catches the growth and anchoring absorbs it. The reader sees the image appear below their anchor, never a jump.
  • When the container width changes (window resize, sidebar toggle), text rows rewrap and every cached height is wrong. Drop measurements for unmounted rows back to estimates, keep the anchor, and let mounted rows re-measure. Don't try to scale old heights by the width ratio: wrapping isn't linear.
  • Never change the size of an observed row from inside the observer callback. The browser delivers that new size in a later frame instead, and logs the console error "ResizeObserver loop completed with undelivered notifications". Changing scrollTop is fine: it doesn't resize anything.

Check your understanding · Exercise 5

The anchor row is 3,010, the first visible row; its top is 40 px above the viewport top, partly scrolled out of view. Rows 3,000 to 3,009 were estimated at 56 px and now measure 80 px each. Row 3,020 also grew by 30 px. What should happen? Select all that apply.

Scrolling performance: staying inside 16 ms

At 60 Hz the browser has about 16.7 ms per frame for everything: our script, style, layout, paint, and compositing. On the reference phone, aim to spend under 8 ms in script. A virtual list does work on scroll by design, so the question is how little work each scroll frame can cost. The runtime-performance lesson covers long tasks and profiling; here we apply it to one loop.

Timeline of one 16.7 ms frame while scrolling. On the compositor thread, painted content moves for the whole frame. On the main thread: computing the range takes under 1 ms, rendering the new rows about 5 ms, layout about 2 ms, ResizeObserver and anchoring about 1.5 ms, paint about 1.5 ms, and the rest of the frame is idle budget.compositor thread: moves painted layersmain thread: one framerangerender rowslayoutROpaintidle0 ms8 ms16.7 ms
Scrolling itself happens on the compositor, so it stays smooth even when the main thread is busy. If render runs long, the compositor scrolls into rows that don't exist yet, and the reader sees blank space.

The scroll loop

  1. The compositor thread moves the already painted content as soon as the finger or wheel moves. This happens even if the main thread is busy, which is why scrolling feels smooth on heavy pages and also why blank areas can appear: the compositor scrolls into rows we haven't rendered yet.
  2. On the main thread, the browser fires scroll at most once per frame, as part of updating the rendering. Read scrollTop there; it is cheap because layout is already clean.
  3. Compute the new range with the binary search (microseconds). If start and end are unchanged, stop. Most scroll frames end here.
  4. If the range changed, render the new rows. Rows that stay in the range must not re-render: stable keys and memoized row components make the update cost proportional to the rows that entered.
  5. Layout runs, ResizeObserver reports the new rows' sizes, the anchor correction runs if needed, and the frame paints.
ts
let frame = 0;
el.addEventListener("scroll", () => {
  if (frame) return;
  frame = requestAnimationFrame(() => {
    frame = 0;
    const next = core.getRange(el.scrollTop, el.clientHeight);
    if (next.start !== range.start || next.end !== range.end) {
      range = next;
      notify(); // React: setState; plain DOM: patch rows
    }
  });
}, { passive: true });
Passive listener, range compare, and at most one render per frame.

Do we need requestAnimationFrame if scroll already fires once per frame? It is a cheap guard: other code can also call scrollTo, and resize and measurement callbacks also want a render. Funneling them all into one frame callback means one range computation and one render per frame, whatever triggered it. { passive: true } changes nothing for scroll itself, which can't be cancelled, but wheel and touchmove listeners on the list (for example to detect user scrolling) must be passive, or the browser waits for them before scrolling.

Fast flings and blank areas

A hard fling on a phone can move several thousand pixels per second, which is 50 to 100 px or more per frame. If rendering the new rows takes 30 ms, the compositor has already scrolled two frames past them and the reader sees empty spacer. Three tools, used together:

  • Overscan in the scroll direction, sized in pixels: half a viewport ahead covers moderate speeds.
  • Cheap rows while flinging: track scroll velocity; above a threshold (say 3,000 px per second) render a lightweight placeholder per row (a grey bar of the right height) and swap in real content when velocity drops. react-virtuoso ships this idea as a scroll seek mode. Placeholders keep the right sizes, so no correction happens.
  • Rows that render in under 1 ms: no data fetching or heavy formatting inside a row. Format dates and highlight log syntax once per item and cache the result by key.

Decision What key does each rendered row get?

Item key (id)

key = getItemKey(index), usually the item's id.

Pros

  • A row that stays visible keeps its component, its state, and its DOM.
  • Prepending doesn't remount the rows on screen.

Cons

  • Rows that leave are destroyed and new ones are created: more allocation during fast scrolls.

Index

key = index.

Pros

  • Nothing to configure.

Cons

  • Prepend 50 items and every visible row gets a new index, so React remounts all of them and the measurement cache is wrong.

Slot recycling

Keep a pool of N row components and key them by slot (index % N), feeding each slot the next item, like native recycler views.

Pros

  • No mounts or unmounts during scroll; DOM nodes are reused.

Cons

  • State leaks between items: an expanded panel, a typed value, or an old image shows on the wrong row.
  • Only safe for stateless rows.

What we pick: Key by item id, always. Use slot recycling only inside a specialized, stateless row such as a plain log line, and only if profiling shows mount cost dominates.

will-change and layer costs

It is tempting to add will-change: transform to every row because rows are moved with transforms. Don't. It asks the browser to give each row its own compositor layer, a separate bitmap in GPU memory. A 1,200 by 600 px row is about 2.9 MB at 4 bytes per pixel; 40 of them is over 100 MB on a phone.

The scroll box is already composited as a whole by the browser, which is what makes scrolling smooth. Rows only change position when the range changes, which is rare, so per-row layers buy nothing.

Check your understanding · Exercise 6

Profiling shows each range change takes 25 ms on the reference phone and readers see blank areas when flinging. Which changes help? Select all that apply.

Reverse and bidirectional lists: chat starts at the bottom

Chat breaks two assumptions of a normal list. It opens at the end, with the newest message at the bottom of the viewport. And it grows at both ends: older history is prepended above as the reader scrolls up, new messages are appended below. The Slack and live comments guides decide when to fetch and how messages arrive; this chapter is about what the list must do so neither change moves the text under the reader's eyes.

Decision How do we build a list that starts at the bottom and prepends history?

flex-direction: column-reverse

Render messages newest first inside a reversed flex container.

The browser starts at the bottom and keeps the bottom fixed as content above grows.

Pros

  • Bottom anchoring for free in normal flow.
  • A popular trick for small, non-virtualized chats.

Cons

  • In current browsers scrollTop is 0 at the bottom and negative going up, so every offset formula flips sign.
  • Keyboard order, screen reader order, and selection follow the reversed DOM unless you fight them.
  • Doesn't help the virtualizer's math at all.

Normal list, plus anchoring

Keep oldest first in the array and in the DOM.

On mount, scroll to the last index aligned to the end.

On prepend, restore the anchor by key; on append, follow the bottom only if the reader was already there.

Pros

  • One set of math for every list.
  • DOM order matches reading order, which is what assistive technology expects.

Cons

  • The list must know whether a data change was a prepend, an append, or neither.

Normal list with stable virtual indexes

Like the previous option, but the page passes a firstItemIndex that decreases by 50 on each prepend (react-virtuoso's approach), so the newest message keeps the same index forever.

Pros

  • Indexes never shift, so index-based caches and scrollToIndex calls made earlier stay valid.

Cons

  • Callers have to do arithmetic with a starting index in the millions; easy to get wrong.

What we pick: A normal list plus anchoring, with measurements and the anchor stored by item key. Keys give us what stable indexes give, without asking callers to manage a starting index. The data change is detected by comparing the first and last keys before and after: a new first key means a prepend, a new last key means an append.

Sequence of prepending older messages, between the Reader, the VirtualList, the Page, and the API. The reader scrolls up. The list calls onRangeChange with first 6. The page sends GET with a before cursor of m_9001. The API replies with 50 older messages. The page passes the new items down. The list restores the anchor, so nothing moves for the reader. Failure path: the API answers 503, and the page shows a Retry row at the top, and nothing moves for the reader.ReaderVirtualListPageAPIscrolls uprange: first 6GET ?before50 older messages50 + old itemsanchor restoredif it fails:503 errorshow Retry row
The list never fetches: it reports its range, and the page decides. The anchor is the key m_9001 and its gap, captured before the new rows render. On failure nothing is inserted, so there is nothing to correct.

Prepending 50 messages without a jump

  1. The reader scrolls up; the list reports first = 6. The page sees it is within 10 rows of the top and fetches the previous page. A loading row shows above message 0.
  2. Before the new data renders, the list records the anchor: the key of the first visible message (m_9001) and its gap from the viewport top (12 px).
  3. The page passes 50 older messages plus the old ones. The list sees the first key changed, so it is a prepend. Offsets are rebuilt; the 50 new rows use estimates.
  4. In the same layout pass, before paint, the list sets scrollTop = offsetOf(m_9001) - 12. With 50 estimated rows of 56 px, that is about 2,800 px further down. The reader sees nothing move.
  5. Rows near the anchor render and get measured. Their corrections are above the anchor, so the normal anchoring from the measurement chapter absorbs them.

In React, the anchor restore must run in a layout effect (or in the virtualizer before the browser paints), not a normal effect. A normal effect runs after paint, so the reader would see one frame with the content shifted 2,800 px, then a snap back.

Following new messages at the bottom

  • The reader is at the bottom when scrollHeight - scrollTop - clientHeight is under a small threshold such as 8 px. Measure it before applying new data, not after: after an append the reader is no longer at the bottom by definition.
  • If they were at the bottom, scroll to the new last row aligned to the end once it has been measured, and keep following while its image loads.
  • If they were reading history, don't move. Show a "3 new messages" button that jumps to the bottom, matching the live comments guide.
  • Own messages are the exception: when the reader sends a message, always scroll to the bottom, even if they were reading history.

Check your understanding · Exercise 7

Users report that loading older messages makes the view jump up by a few hundred pixels, then settle. Which causes are likely? Select all that apply.

Scroll to any row, millions of rows, and two axes

The log viewer links to line 150,000 from an error summary. Search in chat jumps to a message from last month. Both need scrollToIndex(i, { align }), where align is start, center, end, or auto (scroll only if the row isn't fully visible, to the nearest edge).

With fixed heights it is one assignment: scrollTop = i * h, adjusted for alignment. With unknown heights the target offset is a guess, because every row above it may be estimated.

Estimate, scroll, measure, correct

  1. Compute the target from current offsets: for start, offsetOf(i); for center, offsetOf(i) - (viewport - sizeOf(i)) / 2.
  2. Set scrollTop to it, instantly. The rows around the target render with estimated positions.
  3. They are measured. If any of them, or the target itself, changed size, the target offset moved. Anchoring keeps the content still, but the target may now be 30 px off its alignment.
  4. On the next frame, recompute the target and scroll again. Stop when two frames in a row produce the same offset (within 1 px), or after a cap such as 10 frames.

It converges fast because only the rows near the target matter: once they are measured, their offsets relative to each other are exact. Errors in the 149,000 rows above only shift the absolute number, which the next scroll absorbs. The reader sees one jump and at most a 1 or 2 frame settle.

State machine for scrollToIndex with unknown heights. From idle, a call moves to jumped, where scrollTop is set to the estimated target. Rows render and the machine moves to measuring. If the target is stable for two frames it moves to settled. If the target moved more than 1 px it moves to re-aim and back to jumped, up to 10 times. If the user scrolls during measuring, it moves to cancelled.idlejumpedmeasuringsettledre-aimcancelledcallrenderstablemoved over 1 pxscroll againuser scrollscap: 10 tries
The loop converges in a few frames because only rows near the target change its position. User input always wins: any scroll the loop didn't write cancels it.

If the user scrolls by wheel or touch while the loop is correcting, cancel it. Fighting the user's input is worse than landing 30 px off. Detect it by comparing scrollTop at the start of each frame with the value the loop last wrote.

When the list is taller than the browser allows

Browsers store layout positions in fixed-point numbers, so elements have a maximum height, and the limits differ by engine and version. Recent measurements put it at about 16.7 million px for scroll content in Chromium (even though a computed height can read about 33.5 million), about 17.9 million px in Firefox, and about 33.5 million px in Safari. 2 million log lines at 20 px is 40 million px, over every limit.

Past the limit the spacer is silently clipped, and the last rows can't be reached. Design for a safe budget of about 15 million px, under the smallest of these limits.

Scaled scroll space for 2 million log lines. On the left, the logical list is 40,000,000 px tall, above the browser limit. On the right, the spacer is capped at 15,000,000 px. A scale factor k of about 2.67 maps a scrollTop of 5,000,000 on the spacer to a logical offset of about 13,300,000, where rows are found with the normal math and placed relative to the current scrollTop.logical: 40M pxspacer: 15M pxabout the browser limitk = 40M / 15M = 2.67scrollTop: 5,000,000logical offset:about 13,300,000 px
Dragging the thumb reaches every line, but a 100 px wheel step would move about 267 px of content. Apply wheel and key deltas 1:1 in script and use k only for large jumps.

Decision How do we show 2 million rows when the spacer can't exceed about 15 million px?

Scaled scroll space

Cap the spacer at 15 million px and compute a factor k = realTotal / 15,000,000.

The virtual position is scrollTop * k; rows are placed relative to the current scrollTop so they still look adjacent.

Pros

  • The native scrollbar still works and dragging it reaches every row.
  • Small code change inside the core.

Cons

  • A 100 px wheel scroll moves 100 * k px of content: at k = 2.7 small scrolls skip rows unless wheel and key deltas are handled 1:1 in script.
  • Positions get fiddly near the ends.

Pages of scroll space

Split the content into pages of a few million px each.

Scrolling inside a page is 1:1; when the reader nears a page edge, swap to the next page and adjust scrollTop.

Pros

  • Normal scrolling stays exact.
  • An approach used by older data grids.

Cons

  • Dragging the scrollbar thumb only spans one page.
  • Swapping pages during a fling is hard to make invisible.

Custom scrollbar

Hide the native scrollbar, listen to wheel, touch, and keys, keep the position in a number, and draw a thumb yourself.

Pros

  • No height limit at all; full control.
  • Code editors and spreadsheet apps do this.

Cons

  • You must rebuild momentum, scrollbar dragging, keyboard scrolling, and accessibility by hand.
  • Feels foreign if any detail is off.

What we pick: Ship the scaled scroll space as an opt-in mode that turns on only when the total passes the budget, with wheel and keyboard deltas applied 1:1 in script. The admin list and chat never reach it; the log viewer does. If the log team later needs pixel-perfect behavior at 20 million lines, that is the point to move to a custom scrollbar, the way code editors do.

Horizontal lists and grids

Nothing in the core is vertical. Swap scrollTop for scrollLeft, height for width, and translateY for translateX, and the same math runs a horizontal carousel of 5,000 thumbnails.

A grid is two virtualizers: one for rows driven by scrollTop, one for columns driven by scrollLeft, rendering the cross product of the two ranges. A 100,000 by 200 grid with 30 rows and 12 columns visible renders 360 cells, not 20 million. The data table guide builds column virtualization, pinned columns, and header sync on top of exactly this split, so here it is enough that the core takes an axis option and a grid composes two instances.

Check your understanding · Exercise 8

scrollToIndex(150000, { align: "center" }) on chat-like rows with unknown heights lands 40 px off. Which behaviors are correct? Select all that apply.

Architecture: a headless core and thin bindings

We now know the moving parts: offset math, measurement, anchoring, a frame loop, a scroll-to loop, reverse mode. Where does each live? The tempting design is one big React component that does all of it. It is hard to test (every test needs a DOM with real layout), it ties the logic to React, and teams who want a grid or a horizontal list have to fork it.

Split it instead, the way TanStack Virtual does: a headless core that is plain TypeScript, a small DOM adapter, and framework bindings on top.

Component architecture of the virtualized list. The Page, which owns items and fetching, passes props to the VirtualList component and receives onRangeChange. Inside the virtual list package: the VirtualList component handles ARIA, focus, and reverse mode and uses the useVirtualizer hook, the React binding. The hook drives the Core, which holds offsets, the range, and the anchor. A Scheduler runs one requestAnimationFrame per frame for the core. A Measurer with a ResizeObserver sends sizes to the core. A Scroll adapter connects the core to the DOM scroll box and rows, reading and writing scrollTop. The measurer observes the rows.Pageitems, fetchingpropsonRangeChangevirtual list packageVirtualListARIA, focus, reverseuseVirtualizerReact bindingCoreoffsets, anchorSchedulerone rAF a frameMeasurerResizeObserverScroll adapterusessizesDOM: scroll box, rowsscrollTopobserve rows
Only the adapter and the measurer touch the DOM, and only the component knows about ARIA. The core is plain numbers, so the hardest logic is tested without a browser.
PartResponsibilityKnows about
Virtualizer coreSizes by key, the prefix-sum offsets, range with overscan, anchor capture and restore, the scroll-to loop, scale factor for huge lists.Numbers only. No DOM, no React. Unit tested with plain arrays.
Scroll adapterReads scrollTop and viewport size from an element or the window, writes scrollTop, listens to scroll passively, observes the viewport with ResizeObserver.One scroll source. A window adapter adds scrollMargin for content above the list.
Row measurerOne shared ResizeObserver for all mounted rows; maps each entry to an index and key, hands sizes to the core in one batch.The DOM rows, via data-index.
Frame schedulerCoalesces scroll, resize, measurement, and data changes into one requestAnimationFrame per frame.Timing only.
useVirtualizer hookCreates the core and adapter, subscribes to range changes, returns virtual items, total size, a measure ref, and scrollToIndex.React state and refs.
VirtualList componentThe ready-made list: markup, ARIA attributes, focus retention, sticky headers, reverse mode, placeholders while flinging.Accessibility and product defaults.
The pageOwns the data, fetches pages when onRangeChange says the reader is near an edge, renders each row.Its API and its items. The list never fetches.

Why these boundaries? The core is where the bugs are (off-by-one ranges, wrong anchors), so it must be testable without a browser: feed it sizes and a scroll position, assert the range. The adapter is the only place that touches scroll APIs, so supporting the window instead of a box is one new adapter, not a fork.

The hook exists for teams that need custom markup, like the data table; the component exists so the other 90% of uses get accessibility and reverse mode right by default. And fetching stays outside, because chat, logs, and the admin list fetch in three different ways.

Data flow for the chat history. The Page store requests GET with a before cursor from the Messages API, which returns 50 items. The store passes items to the VirtualList as count and keys. The VirtualList renders about 30 rows at their offsets into the DOM. Scroll events go from the DOM to the Scroll adapter, which passes scrollTop to the Core. The Core reports onRangeChange to the Page store, which may fetch the next page. The Measurer receives resize entries from the DOM rows and sends sizes by key to the Core.Messages APIcursor pagesPage storeitems by keyVirtualListcount, keysCorerange, offsetsScroll adapterDOM rowsabout 30 rowsMeasurerResizeObserverGET ?before50 itemsitemsrows at offsetsscroll eventscrollToponRangeChangeresize entriessizes by key
Data flows down as props and rows; geometry flows back up as scroll positions and sizes. The only thing that reaches the page is the visible range, which is all it needs to decide when to fetch.

One frame through the parts

  1. The adapter's scroll listener fires and asks the scheduler for a frame.
  2. In the frame callback the core computes the range from the new scrollTop. Unchanged: done.
  3. Changed: the hook sets state, React renders the rows that entered, and unmounts the ones that left.
  4. The measurer observes new rows. Its callback, after layout, sends sizes to the core; the core rebuilds offsets from the lowest dirty row, restores the anchor through the adapter, and asks for another frame if the total size changed.
  5. The core emits onRangeChange({ first, last }) at most once per frame; the page may start a fetch, and new data flows back down as props.

Check your understanding · Exercise 9

Which responsibilities belong in the headless core rather than the React component? Select all that apply.

Data model and the component API

The list holds very little data of its own: it never owns the items. What it owns is geometry: sizes, offsets, and where the reader is. Getting the keys of that geometry right is what made prepending and measurement work in earlier chapters.

ts
type Key = string | number;

type VirtualItem = {
  index: number;   // position in the current array
  key: Key;        // from getItemKey(index), stable across prepends
  start: number;   // offset from the top of the list, in px
  size: number;    // measured, or estimated if never rendered
};

type CoreState = {
  count: number;
  measured: Map<Key, number>;     // real sizes, by key
  offsets: Float64Array;          // count + 1 entries
  dirtyFrom: number;              // lowest index whose offset is stale
  scrollOffset: number;           // last scrollTop read or written
  viewportSize: number;
  range: { start: number; end: number; first: number; last: number };
  anchor: { key: Key; gap: number } | null;
  scrollTarget: { index: number; align: Align; tries: number } | null;
  isScrolling: boolean;
};
The core's state. Everything is derived from count, keys, sizes, and the scroll position.
DataLives inWhy there
Items (messages, users, log lines)The page's store or server cacheThe list only asks for a key and a rendered row by index.
Measured sizesCore, a Map keyed by item keySurvives prepends and range changes. The page may keep it across navigations to restore Back without re-measuring.
OffsetsCore, a Float64ArrayDerived from sizes; rebuilt lazily from dirtyFrom.
Scroll positionThe DOM (scrollTop), mirrored in the coreThe browser is the source of truth; the core reads it each frame. Save it with the anchor key in history state for Back.
Focused row keyThe componentNeeded to keep that row mounted (accessibility chapter).

The interface comes next. Three teams with different markup will call it, so the first question is how much of the markup the list should own. Chat and the admin list want a ready list with accessibility built in. The data table team needs table rows and two axes, which a fixed component can't give them.

Decision Do we ship a headless hook, a component, or both?

Component only

VirtualList with renderItem; markup and styles are ours.

Pros

  • Easiest to adopt.
  • ARIA, focus, and reverse mode are always right.

Cons

  • Tables, grids, and custom markup can't use it without escape hatches.
  • Every new layout request becomes a prop.

Headless hook only

useVirtualizer returns items, total size, and a measure ref; callers write all markup.

Pros

  • Fits any markup: tables, grids, carousels.
  • Small and stable surface.

Cons

  • Each team re-implements ARIA, focus retention, and sticky headers, and most will get one wrong.

Both, layered

The hook is the foundation; the component is built with the hook and is what the docs recommend first.

Pros

  • Default use gets accessibility for free; advanced use isn't blocked.
  • The component's code is a working example of using the hook correctly.

Cons

  • Two surfaces to document and version.

What we pick: Both, layered. The component-api-design lesson's rule applies: make the common case one component with good defaults, and expose the primitive underneath for the cases you can't predict. The data table team uses the hook; chat, logs, and the admin list use the component.

ts
type VirtualListProps = {
  count: number;
  getItemKey: (index: number) => Key;
  renderItem: (index: number, state: { isScrolling: boolean }) => ReactNode;
  estimateSize: (index: number) => number; // or a number for every row
  fixedSize?: number;           // skips measurement entirely
  overscan?: number;            // in px, default half a viewport
  horizontal?: boolean;
  reverse?: boolean;            // start at the end, follow appends at bottom
  scrollElement?: "self" | "window";
  scrollMargin?: number;        // px of page content above the list
  initialIndex?: number;        // where to start, e.g. last read message
  totalCount?: number;          // for aria when count is a loaded slice
  label: string;                // accessible name of the list
  onRangeChange?: (range: { first: number; last: number }) => void;
  onAtEndChange?: (atEnd: boolean) => void;
  placeholder?: (index: number) => ReactNode; // shown while flinging
};

type VirtualListHandle = {
  scrollToIndex(index: number, opts?: { align?: Align; behavior?: "auto" | "smooth" }): void;
  scrollToKey(key: Key, opts?: { align?: Align }): void;
  scrollToOffset(px: number): void;
  measure(): void; // drop cached sizes, e.g. after a font size change
};
The component's props. The hook takes the same options minus rendering.
  • getItemKey is required, not optional. A default of the index would silently break prepends and focus retention, the two things hardest to debug later.
  • overscan is in pixels, because a row count means different things for 20 px log lines and 600 px messages.
  • fixedSize is a separate prop from estimateSize so the fast path is explicit: no observer, no corrections.
  • onRangeChange reports visible rows, not rendered rows, so the page's prefetch distance doesn't change when someone tunes overscan.

Check your understanding · Exercise 10

Which API choices protect callers from subtle bugs? Select all that apply.

Accessibility and find-in-page: paying back what we removed

Virtualization deletes rows from the page on purpose. Sighted mouse users never notice. Three other groups do: screen reader users, whose software reads the DOM; keyboard users, whose focus lives on a DOM element; and everyone who presses Ctrl+F, because the browser only searches the DOM.

Each needs a specific fix, and an interviewer at a senior level will ask about all three. The aria-patterns, keyboard-navigation, and focus-management lessons cover the general patterns; here is what changes when most rows don't exist.

Announcing positions in the full list

With 30 rows mounted, a screen reader may say "list, 30 items" and read the first mounted row, user 1,998, as "1 of 30". ARIA lets us state the real numbers: aria-setsize on each item is the full count, and aria-posinset is its 1-based position. For tables and grids the equivalents are aria-rowcount on the grid and aria-rowindex on each row. When the total is unknown, as in chat history, aria-setsize="-1" says so explicitly.

How much of this is spoken depends on the screen reader and browser pair. Positions such as "2,001 of 100,000" are widely supported; the list's own item count may still reflect the mounted rows in some pairs. Test with at least NVDA or JAWS on Windows and VoiceOver on macOS and iOS.

html
<div role="list" aria-label="Users" aria-describedby="list-hint">
  <div role="listitem" aria-setsize="100000" aria-posinset="1998">…</div>
  <div role="listitem" aria-setsize="100000" aria-posinset="1999">…</div>
  <div role="listitem" aria-setsize="100000" aria-posinset="2000">…</div>
  <!-- … about 30 items … -->
</div>
<p id="list-hint" hidden>Use the search box above to find a user.</p>

<!-- Log viewer: a grid, because lines have columns and arrow-key navigation -->
<div role="grid" aria-label="Build log" aria-rowcount="200000">
  <div role="row" aria-rowindex="150000">…</div>
</div>
What the admin list renders around row 2,001: 30 items in the DOM, positions in the full list.

Pick the role by behavior. A plain list for the admin list, where each row holds a link and a menu. A grid for the log viewer, where arrow keys move between lines and cells. For chat, many products use a list or the feed pattern, whose items are article elements with aria-posinset and aria-setsize, and which sets aria-busy="true" on the feed while a page loads so the screen reader doesn't read half-inserted content.

What a screen reader sees in the virtualized user list. Of 100,000 rows, rows 1,998 to 2,027 are mounted, plus row 1,990, which has keyboard focus and stays mounted off screen. A screen reader can announce Ada Lopez as 2,001 of 100,000, because each row has aria-posinset and aria-setsize; the exact wording depends on the screen reader and browser.100,000 rowsfocused row 1,990kept mounteda screen reader can hearAda Lopez, 2,001 of 100,000aria-posinset=2001aria-setsize=100000mounted: 1,998to 2,027
The DOM holds 31 rows, but aria-setsize and aria-posinset tell the screen reader where each one sits in the full list. The focused row is the one exception to the window.

Never unmount the focused row

A keyboard user tabs to the menu button in row 2,000, then scrolls with the mouse wheel to row 2,300. Row 2,000 leaves the range and unmounts. The focused element is gone, so focus falls back to body. The next Tab starts from the top of the page, and a screen reader loses its place entirely.

The fix is the same trick as sticky headers: the range extractor always adds the focused row's index, so the focused row stays mounted at its true offset, off screen, until focus leaves it. That is one extra row in the DOM, at most.

  • Track focus with focusin and focusout on the list, storing the focused row's key, so it survives prepends.
  • Arrow keys in a grid or listbox: call scrollToIndex(next, { align: "auto" }), then focus the new row in a layout effect after it mounts. Never call focus() on a row that doesn't exist yet.
  • Home and End jump to row 0 and the last row through the scroll-to loop, then move focus.
  • Use a roving tabindex (one row with tabindex="0", the rest -1) so Tab leaves the list in one press instead of stopping on every mounted row.

Find-in-page can't find what isn't there

Ctrl+F searches the DOM. Text in unrendered rows is not found, and the match count is wrong. hidden="until-found" doesn't help: it reveals hidden elements that exist, and our rows don't. There is no way to make native find search data that isn't in the page, so each list chooses one of three answers, and the guide's position is that the choice should be explicit, not discovered by a user.

ApproachFitsCost
In-list search box that filters or jumps with scrollToKeyAdmin list, chat (server search)A real feature to build; users must learn to use it instead of Ctrl+F.
Intercept Ctrl+F or Cmd+F while the list has focus and open our own search; a second press opens the browser'sLog viewer, code viewsOverriding a browser shortcut surprises people; only do it inside a focused, app-like surface.
Don't virtualize: render everything with content-visibility: autoLists under a few thousand rows where find mattersBack to full memory cost; fine at that size.

Check your understanding · Exercise 11

A screen reader user arrows down through the virtualized user list. Which problems would they hit without the fixes in this chapter? Select all that apply.

Server rendering, Back navigation, memory, and proving it works

Rendering the first window on the server

The admin page is server-rendered so the first screen of users shows before JavaScript loads. The server has no viewport and no scrollTop, so the core can't compute a range. Give it an assumed viewport instead: TanStack Virtual calls this initialRect. With an assumed height of 900 px and estimates of 48 px, the server renders rows 0 to 18 plus overscan, at estimated offsets, inside a spacer sized from estimates.

  • The client's first render must produce the same rows, so it uses the same assumed rect. Only after hydration does the adapter read the real viewport and update. Reading window.innerHeight during the first client render causes a hydration mismatch.
  • Server-rendered rows should be in normal flow (the padding mode from the DOM chapter) so the browser places them correctly before any measurement. If estimates are wrong, only the spacer below the viewport changes size, which doesn't shift anything on screen and doesn't count as layout shift.
  • Server rendering a window doesn't make the list crawlable. If search engines must see all items, they need real paginated URLs, as the infinite scroll guide describes.
  • For chat in reverse mode, skip server rendering of rows: the server can't know which end the reader starts at relative to their viewport, and a skeleton is cheaper than a wrong first window.

Coming back to the same place

The browser's own scroll restoration can't help: when the reader comes back, the spacer is built from estimates, so the saved scrollTop points at a different row. Save the anchor instead: the first visible row's key and its gap, plus the measured size map, in history.state or a module-level cache keyed by the page URL.

On return, pass initialIndex from the key, restore the gap, and reuse the sizes so offsets near the anchor are already exact. Set history.scrollRestoration = "manual" on these pages so the browser doesn't apply its own stale value first.

Memory that grows anyway

  • The size map grows with the data: 200,000 numbers keyed by string ids is a few MB. Fine, but drop it when the list unmounts.
  • Every row's ref callback must unobserve on unmount. One shared observer that never forgets a row keeps detached nodes alive; the memory-leaks lesson shows how to spot them in a heap snapshot.
  • Rows with images that unmount let the browser drop their decoded bitmaps, which is a hidden win of windowing on image-heavy chat.
  • The data itself is the page's problem: a log viewer holding 2 million parsed lines needs compact storage (arrays of strings, not objects per line), and maybe a worker that parses and indexes.

Testing the parts

LayerHow to testWhat it catches
Core mathUnit tests with plain arrays; property tests with random sizes asserting the range always covers the viewportOff-by-one ranges, wrong binary search bounds, broken anchors
Anchoring and prependReal browser tests (Playwright): scroll up through estimated rows, assert the anchor row's screen position stays within 1 pxJumps; jsdom has no layout, so it can't test this
AccessibilityAssert ARIA attributes; scripted keyboard runs that scroll 500 rows and check document.activeElementLost focus, wrong positions
PerformanceTrace a scripted fling on a throttled CPU (4x to 6x slowdown); count frames over 16 msRegressions in row render cost

Measuring it in production

Report a few numbers from real devices: time to first window, the share of scroll frames that render, how many anchor corrections happen per 1,000 px scrolled (a high number means bad estimates), and blank frames, detected by checking in a frame callback whether the viewport's top or bottom falls outside the rendered range. Interaction to Next Paint covers keyboard navigation; in Chromium, the Long Animation Frames API attributes slow scroll frames to the script that caused them.

Check your understanding · Exercise 12

The server-rendered admin list shows a hydration warning and a visible flicker on load. Which changes fix it? Select all that apply.

Running it in the interview

A virtualized list question is a depth test. Everyone knows "only render what's visible". The interviewer is listening for whether you can do the math out loud, whether you know where variable heights break things, and whether you bring up accessibility before being asked. Here is a 60 minute plan.

MinutesWhat to doWhat to say out loud
0 to 6Requirements: row count, fixed or unknown heights, direction (chat), box or window, server rendering, devices."Unknown heights and reverse mode are the two answers that change the design most."
6 to 10Why virtualize; mention content-visibility as the cheap alternative."Under a few thousand rows I'd try CSS first and keep find-in-page."
10 to 20Draw the scroll box, spacer, and placed rows. Do the fixed math, then the prefix sum and binary search.Say the numbers: "100,000 rows of 48 px, scrollTop 96,000: first row 2,000."
20 to 32Variable heights: estimate, ResizeObserver, anchor correction. Then prepend in chat."Measurements and the anchor are stored by key, never by index."
32 to 40Architecture: headless core, adapter, measurer, hook, component. The API."The list reports its range; the page fetches."
40 to 50Accessibility and find-in-page, then performance during flings."The focused row is always mounted."
50 to 60Follow-ups: scroll to index, browser height limits, grids, server rendering.Name the trade-off in every answer.

Expect the interviewer to pick one thread and pull hard. The most common are the scrollbar, momentum on phones, text selection, and tables. Each answer below names the cause, the fix, and what the fix costs.

Follow-ups to expect

Think first

"Why does the scrollbar thumb change size while I scroll a list with variable heights?"

Think first

"On iOS, a fling sometimes stops dead while scrolling up through chat history. Why?"

Think first

"A user selects text across 200 messages and copies it. What happens?"

Think first

"Can I use this for a table with sticky headers and 200 columns?"

Check your understanding · Exercise 13

Which answers would a strong senior candidate give? Select all that apply.

Summary

  • Virtualize when the row count is large or unbounded; under a few thousand rows, try content-visibility: auto first, which keeps find-in-page and the accessibility tree.
  • The structure is a scroll box, one spacer with the total height, and about 30 rows placed at their offsets. Fixed heights need one division; variable heights need a prefix-sum offset array and a binary search.
  • Unknown heights follow estimate, measure with ResizeObserver, correct. Every correction restores an anchor row so the content under the reader doesn't move.
  • Store sizes, anchors, and focus by item key, never by index: prepending in chat shifts every index.
  • Keep scroll frames cheap: compare ranges before rendering, memoize rows keyed by id, overscan toward the scroll direction, and use placeholders during fast flings. No will-change on rows.
  • scrollToIndex with unknown heights is a loop: jump, measure, correct, stop when stable or when the user scrolls.
  • Elements can't be taller than roughly 16.7 to 33.5 million px depending on the browser; past that, scale the scroll space or draw your own scrollbar.
  • Pay back what virtualization removes: aria-setsize and aria-posinset (or aria-rowcount and aria-rowindex), a focused row that stays mounted, and a search for rows Ctrl+F can't see.
  • Build a headless core with no DOM, a scroll adapter, and a component on top of a hook. The list reports its range; the page fetches.

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