cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

List virtualization

Rendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.

The problem: a list that is too big to draw

Picture a support tool that shows every ticket for a large customer: 10,000 rows. Each row has an avatar, a subject, a status badge, a date, and two buttons. That is about 15 DOM elements per row, so the list alone is 150,000 elements.

The data is only about 2 MB of JSON. The page, though, freezes for a second or two when it opens, scrolling stutters, and typing in the search box above the list lags.

The data was never the problem. The browser has to build, style, lay out, and paint all 150,000 elements, while the screen shows about 20 rows. The user sees 0.2% of the list and pays for 100% of it.

Where the cost goes

Every element in the DOM costs something in four places. The lesson on reflow, repaint, and composite covers the pipeline in depth; here is the short version as it applies to a long list.

  • Creating it. Your framework runs component code for each row, then the browser creates the nodes. In React, 10,000 rows means 10,000 component renders before anything shows.
  • Style. The browser matches CSS rules against every element and computes its styles.
  • Layout. The browser works out the size and position of every box. A row near the top that grows by 20 px can force it to place every box below again.
  • Memory. The lesson on estimation budgets 0.5 to 1 KB per element all in, so 150,000 elements is 75 to 150 MB: enough for a phone to kill the tab in the background.

For years Lighthouse warned about DOM size above about 800 elements and failed the audit above about 1,400. Newer Lighthouse versions measure slow style and layout updates instead, but 1,400 is still a handy guideline. Our ticket list is about 100 times past it.

A bar chart of DOM elements for a ticket list of 10,000 rows at 15 elements per row, drawn to scale. Rendering every row: 150,000 elements. Infinite scroll after 2,000 rows have loaded: 30,000. The Lighthouse guideline: 1,400. A virtual list that keeps 30 rows mounted: 450 elements, a sliver too thin to see next to the first bar.DOM elements, to scalerender all 10,000 rows150,000 elementsinfinite scroll, 2,000 rows loaded30,000 elementsLighthouse guideline1,400 elementsvirtual list: 30 rows450 elementsmemory: 75 to 150 MB against under 0.5 MB
Drawn to scale, the guideline and the virtual list are barely visible next to the full list. Virtualization makes the DOM size depend on the screen, not on the data.

The idea in one sentence

List virtualization (also called windowing) means you keep all 10,000 items as data, but only create DOM for the rows inside the visible area plus a few extra above and below. As the user scrolls, you swap which rows exist. A spacer keeps the scroll bar the right size, so the list looks and feels like it has 10,000 rows.

With 20 visible rows and 5 extra on each side, the DOM holds 30 rows, about 450 elements, no matter whether the list has 100 items or 1,000,000. The cost of the list now depends on the size of the screen, not the size of the data.

Check your understanding · Exercise 1

A list of 5,000 products renders in 1.8 seconds on a mid-range phone. The JSON for all 5,000 is 600 KB and parses in 15 ms. What is the main cost to cut? Pick one.

The mental model: a window over a tall strip

Think of a film strip 10,000 frames long and a small window you look through. You never need the whole strip in front of you, only the few frames behind the window. As you slide the window, frames leave on one side and new ones arrive on the other. Virtualization does the same with rows.

Three layers: viewport, spacer, rendered rows

Every virtual list, in every library, is built from the same three layers. Once you can draw them, you can explain any library's API.

  1. The viewport (also called the scroll container). A box with a fixed height, say 600 px, and overflow-y: auto. It is the window. The user scrolls it, and it tells you how far they scrolled with scrollTop.
  2. The spacer. One empty element inside the viewport whose height is the height of the whole list: 10,000 rows x 40 px = 400,000 px. It holds no content. It exists so the browser draws a scroll bar of the right size and lets the user scroll the full distance.
  3. The rendered rows. The 15 to 30 rows that are visible or nearly visible. Each is placed at its true position in the full list, either with position: absolute; top: ... or with transform: translateY(...). Row 5,000 sits at 200,000 px, exactly where it would be if all rows existed.
A virtual list of 10,000 rows of 40 pixels. A dashed spacer 400,000 pixels tall gives the scroll bar its full height. A viewport frame, 600 pixels tall and scrolled to 12,000 pixels, covers the visible rows 300 to 314. Overscan rows 295 to 299 sit just above it and 315 to 319 just below. Rows 0 to 294 and 320 to 9,999 are not in the DOM: the spacer is empty there.spacer: 400,000 pxrows 0 to 294not in the DOMviewport: 600 pxoverscan: 295 to 299visible: 300 to 314overscan: 315 to 319rows 320 to 9,99925 rows in the DOM, not 10,000
The spacer fakes the full height, so the scroll bar is right. Only the visible rows and a band of overscan on each side are real elements, each placed at its true offset, index x 40 px.

The user cannot tell the difference. The scroll bar is the right size, the rows are where they should be, and scrolling is smooth. The only thing missing is the 9,970 rows nobody is looking at.

The loop that runs on every scroll

A virtual list is a small loop. The browser fires a scroll event as the user scrolls, and the list reads scrollTop, works out which rows overlap the visible area, and renders that slice. Rows that left the window are unmounted; rows that entered are mounted. Nothing else changes.

text
scroll event
  -> read scrollTop (say 12,000 px)
  -> first visible row = floor(12,000 / 40) = 300
  -> last visible row  = ceil((12,000 + 600) / 40) - 1 = 314
  -> add overscan: render rows 295 to 319
  -> place each row at index x 40 px
  -> browser paints the new frame
One pass of the loop for a 600 px viewport and 40 px rows.

Scroll events can arrive every frame, 60 to 120 times a second, so the loop must be cheap. Finding the range is a division (or a binary search for variable rows) and takes microseconds. The real cost is mounting new rows, so what matters is how many rows change per frame, not how long the list is.

What it is not

Virtualization is often mixed up with three nearby ideas. Keep them apart, because an interviewer will often ask which one you mean.

Infinite scroll and virtualization are often used together: one decides what data to fetch, the other decides what DOM to keep.
TechniqueWhat it limitsWhat stays in memory
VirtualizationDOM: only visible rows are renderedAll the data you have loaded
PaginationData: only one page is fetched and shownOne page of data and DOM
Infinite scrollNetwork: the next page is fetched near the bottomEvery page loaded so far, unless you also virtualize
Lazy loadingWork for heavy parts such as images, until they come near the screenAll rows, with light placeholders

Think first

Why does a virtual list need a spacer at all? Could it just render 30 rows and move them as the user scrolls?

Fixed-height rows, step by step

The simplest case is a list where every row has the same height, like a log viewer or a table of transactions with one line per row. The math is simple enough to do in your head, and it is the base every other case builds on.

A worked example

Take 10,000 rows, each exactly 40 px tall, in a viewport 600 px tall. The user has scrolled 12,000 px down. Here is everything the list needs to know, in order.

  1. Total height: 10,000 x 40 = 400,000 px. This is the spacer's height, and it only changes when the item count changes.
  2. First visible row: floor(12,000 / 40) = row 300. Its top edge is exactly at the top of the viewport.
  3. Last visible row: the bottom of the viewport is at 12,600 px, so ceil(12,600 / 40) - 1 = row 314. That is 15 rows on screen.
  4. Overscan: with 5 extra rows on each side, render rows 295 to 319: 25 rows in total.
  5. Position: row 295 goes at 295 x 40 = 11,800 px from the top of the spacer, row 296 at 11,840 px, and so on.

If the user scrolls 20 px further, to 12,020 px, row 300 is still the first visible row, but row 315 now peeks in at the bottom, so the list adds it. At 12,040 px row 295 leaves. Every 40 px of scrolling swaps one row in and one row out, which is why fixed rows are so cheap to scroll.

The whole algorithm in 12 lines

Here is a complete range function for fixed rows. It is framework-free: give it the scroll position and sizes, get back which rows to render and where the first one goes. It clamps at both ends, so a negative scrollTop (the bounce on iOS) or a list shorter than the viewport does not break it.

ts
function getRange(scrollTop: number, viewportHeight: number,
                  rowHeight: number, count: number, overscan = 3) {
  const total = count * rowHeight;
  if (count === 0) return { start: 0, end: -1, offset: 0, total };
  const top = Math.max(0, Math.min(scrollTop, total - viewportHeight));
  const first = Math.floor(top / rowHeight);
  const last = Math.min(count - 1,
    Math.ceil((top + viewportHeight) / rowHeight) - 1);
  const start = Math.max(0, first - overscan);
  const end = Math.min(count - 1, last + overscan);
  return { start, end, offset: start * rowHeight, total };
}

getRange(12000, 600, 40, 10000, 5);
// { start: 295, end: 319, offset: 11800, total: 400000 }
Rows start to end are rendered (both included); offset is where the first one goes.

Test the edges: scroll at 0, scroll past the end, a list shorter than the viewport, an empty list, and a scrollTop halfway through a row. These catch nearly every off-by-one bug.

Wiring it up to the DOM

In React, the component keeps scrollTop in state, updates it on scroll, and renders the slice. The rows sit inside the spacer, inside one wrapper moved down by offset.

tsx
function VirtualList({ items, rowHeight = 40, height = 600 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const { start, end, offset, total } =
    getRange(scrollTop, height, rowHeight, items.length, 5);

  return (
    <div style={{ height, overflowY: "auto" }}
         onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      <div style={{ height: total, position: "relative" }}>
        <div style={{ transform: `translateY(${offset}px)` }}>
          {items.slice(start, end + 1).map((item) => (
            <Row key={item.id} item={item} height={rowHeight} />
          ))}
        </div>
      </div>
    </div>
  );
}
Keys are the item's id, not its index, so a row's state does not jump to another item when the slice moves.

Three details in this sketch matter more than they look.

  • transform for the moving wrapper. Changing transform usually skips layout; changing top does not.
  • Stable keys. With the index as key, a row's component is reused for a different item when the slice moves, so input text, focus, and open menus jump to the wrong item.
  • Cheap rows. A row that mounts in 15 ms makes every scroll that brings in a new row miss a frame. Memoize rows (React.memo) so rows that stay in range do not re-render.
A sequence diagram of one flick in a virtual list, with four lifelines: the user, the compositor, the list code on the main thread, and the DOM. The user flicks 80 pixels. The compositor moves the page at once, without waiting for JavaScript. It then sends a scroll event to the list code, which computes the new range in microseconds and mounts 2 new rows and unmounts 2 old ones, at about 1 millisecond per row. The DOM paints the new rows in the next frame.usercompositorlist codeDOMflick: 80 pxpage moves at oncescroll eventmount 2, unmount 2new rows paintedrange check: microsecondsrow mount: about 1 ms each
Scrolling runs ahead of your code: the compositor moves what is already drawn, and your list catches up a frame later. Keeping the per-frame work to a couple of row mounts is what keeps the list smooth.

Check your understanding · Exercise 2

Rows are 50 px tall, the viewport is 500 px, there are 1,000 rows, and scrollTop is 2,475. With an overscan of 2, which rows are rendered? Pick one.

Overscan, fast scrolling, and the frame budget

The worked example rendered 5 extra rows on each side. Those extra rows are called overscan (TanStack Virtual's name; react-window calls it overscanCount, and react-virtuoso uses increaseViewportBy, measured in pixels). This chapter explains why they exist and how to pick the number.

Why fast scrolling shows blank gaps

In modern browsers, scrolling does not wait for your JavaScript. The browser architecture lesson explains that the compositor thread moves the page on screen by itself, using layers it already has. Your scroll handler runs later on the main thread, and the new rows appear one frame or more after that.

So during a quick flick the compositor can move past the rows you have rendered before your code catches up, and the user sees empty space for a frame or two. A fast fling on a phone moves 3,000 to 5,000 px per second: 50 to 80 px every frame at 60 Hz.

Overscan is the buffer for that gap. If 5 rows (200 px) are ready below the visible area, the compositor has 200 px of real content to show before it runs out, which covers about 3 frames of a fast fling.

Two phone screens during the same fast flick. Left, with no overscan: four rows are drawn and the bottom of the screen is a blank band for one or two frames, because the compositor scrolled past the rendered rows before the list code ran. Right, with 5 rows of overscan: the screen is full, because the extra rows below were already mounted, and the list code has time to catch up.overscan 0overscan 5blank band for 1 to 2 framesoverscan rows fill the gapsame flick: about 80 px per frame at 60 Hz
The compositor can only show rows that exist. Overscan gives it a few rows of real content past the edge, which covers the frame or two your code needs to catch up.

Choosing the number

Overscan is a trade between blank gaps and work per frame. More overscan means fewer gaps, but more rows to mount when the range moves and more DOM at rest. A rough guide:

Libraries default low: TanStack Virtual renders 1 extra row by default and react-window (version 2) renders 3. Tune it by watching a fast fling on a real mid-range phone.
OverscanRows in DOM (15 visible)Good for
015Rarely: fast scrolls show a flash at the edge
2 to 5 rows19 to 25Most lists with cheap rows: tables, logs, menus
5 to 10 rows25 to 35Fast-flung feeds on phones, rows with images
20+ rows55+Rarely worth it: you start to lose the point of virtualizing

Some lists use direction-aware overscan: more rows in the scroll direction and fewer behind, say 8 below and 2 above while scrolling down.

The frame budget

At 60 frames a second the browser has 16.7 ms per frame for everything: your scroll handler, React's render, style, layout, and paint. At 120 Hz it has 8.3 ms. If mounting the new rows takes 25 ms, at least one frame is dropped and the user feels a stutter.

Cost per frame is roughly rows that change times the cost of one row. Scrolling 80 px a frame through 40 px rows mounts about 2 rows a frame: at 1 ms each, well inside the budget. A list that re-renders all 25 rows on every scroll (rows not memoized, or keys that change) pays 25 ms and stutters.

  • Keep the scroll handler tiny. Read scrollTop, set state, return. Avoid wheel or touchmove listeners that are not passive on the container, because they make the browser wait for your code before it scrolls.
  • Do not debounce the scroll handler. Debouncing waits for scrolling to stop, so the list shows blank space for the whole scroll.
  • Show a cheap placeholder while flinging if rows are heavy: some libraries, such as TanStack Virtual, expose isScrolling, so you can draw a grey box instead of a chart until scrolling stops.
  • Move heavy work out of rows. Format dates and parse markdown once when the data arrives, not in each render.

Try it below. The lab has 10,000 rows. Turn virtualization off and on and change the overscan, then compare how many DOM nodes the list holds and the estimated cost of a frame while scrolling. The costs come from a rough model (0.05 to 0.5 ms to mount a row, plus about 1 microsecond of style and layout per element), not from measurements.

Rendering:
Overscan:
Rows:
Scrolling:
A rough model of the cost on a mid-range phone with virtualization and an overscan of 3 rows, simple rows (8 elements). First render: 1.2 ms. One scroll frame: 0.6 ms. Layout after a change near the top: 0.2 ms. The frame budget at 60 Hz is 16.7 ms.rough model, estimated ms, bars capped at 100 msfirst render1.2 msscroll frame0.6 msafter a change0.2 ms16.7 ms budget
The DOM holds only the window, so the cost depends on the screen, not the list size. Overscan trades a little more work for fewer blank flashes.
Rows in the DOM21 (rows 2,997 to 3,017) of 10,000
DOM elements168 (under the 1,400 guideline)
DOM memory, about 0.75 KB each0.12 MB
Rows mounted per scroll frame2 in, 2 out
Blank flashes while scrollinglikely: 160 px can pass in two frames, overscan covers 120 px

Check your understanding · Exercise 3

Users report white flashes at the bottom of a virtual feed when they fling on Android. Rows are memoized and mount in about 1 ms each. Overscan is 1. What do you change first? Pick one.

Variable heights: estimate, measure, cache

Real lists are rarely that tidy. A chat message can be one line or twenty; a feed card may or may not have an image. You cannot find row 300 by computing 300 x 40, because the rows above it are all different sizes.

Worse, you usually do not know a row's height until it is in the DOM. Its height depends on the text, the font, the width of the screen, and whether its image has loaded. So a variable-height list has to work with guesses first and real sizes later.

The position cache

The core data structure is a position cache: for each row, its height and its top offset (the sum of the heights of all rows above it). This running total is also called a prefix sum. With it, the list can answer both questions it needs on every scroll.

  • Where does row i start? Read offsets[i]. One array lookup.
  • Which row is at pixel y? The offsets only ever grow, so binary search them for the last row whose offset is at or below y. For 10,000 rows that is about 14 comparisons.
The position cache for six rows. Heights: 72, 80, 131, and 48 pixels, measured, then two rows still at the estimate of 80. Offsets, the running total of the heights above each row: 0, 72, 152, 283, 331, and 411. A binary search for scrollTop 300 finds row 3, which starts at 283, as the first visible row.heightoffsetrow 0720row 18072row 2131152row 348283row 480?331row 580?411scrollTop 300: first visible is row 3green: measured, hatched: still an estimate
Each offset is the sum of the heights above it, so the offsets only grow and a binary search finds the row at any scrollTop. When an estimate is replaced by a measurement, every offset after it shifts.
ts
// offsets[i] = top of row i; offsets has count + 1 entries,
// and offsets[count] is the total height.
function rowAt(offsets: number[], y: number): number {
  let lo = 0, hi = offsets.length - 2;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1;
    if (offsets[mid] <= y) lo = mid; else hi = mid - 1;
  }
  return lo;
}

// heights 40, 80, 40, 120 -> offsets 0, 40, 120, 160, 280
rowAt([0, 40, 120, 160, 280], 130); // 2
The first visible row is rowAt(offsets, scrollTop); walk forward until a row's top passes the viewport's bottom.

Estimate first, measure when rendered

Every row starts with an estimated size: a guess you supply, such as 80 px for a chat message. The list builds the position cache from the estimates, so the spacer has a height and the scroll bar works from the first frame.

When a row mounts, the list measures it. The modern way is a ResizeObserver on each rendered row: it reports the row's real size after layout, and it fires again whenever the size changes later, for example when an image loads or the user expands a reply. The list writes the real height into the cache and fixes the offsets of every row below it.

A state machine for one row's size. It starts as estimated, a guess such as 80 pixels. When the row enters the range it is mounted and observed. When the ResizeObserver fires, it becomes measured, with its real height in the cache and the offsets below it fixed. When it leaves the range it is unmounted but its size stays cached, so it returns to mounted with an exact size. A note says that if the row resizes it is measured again, and if the container width changes every row goes back to estimated.estimatedguess: 80 pxmountedin DOM, observedmeasuredreal: 131 pxunmountedsize still cachedenters rangeobserver firesleaves rangeenters againwidth changes:all back toestimatedon resize:measure again,fix offsets
Sizes outlive the DOM: a row that scrolls away keeps its measured height in the cache, so scrolling back is exact. A change of container width makes every cached height suspect, because text wraps differently.
ts
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const index = Number(entry.target.getAttribute("data-index"));
    const height = entry.borderBoxSize[0].blockSize;
    if (height !== cache.size(index)) cache.setSize(index, height);
  }
  scheduleRender(); // once per batch, not once per row
});

// when a row mounts: ro.observe(rowElement)
// when it unmounts:  ro.unobserve(rowElement)
TanStack Virtual's measureElement works this way: rows carry data-index and one observer measures them all.

Updating offsets is the expensive part: if row 300 changes, rows 301 to 9,999 all move. Recomputing 10,000 sums still takes well under a millisecond, so libraries simply recompute from the changed row down, lazily. Lists with hundreds of thousands of rows sometimes use a Fenwick tree, which updates and sums in log n steps.

Why the estimate matters

A bad estimate does not break the list, but users feel it in two ways.

  • The scroll bar lies. If you estimate 50 px and real rows average 150 px, the spacer is 500,000 px instead of 1,500,000 px. The thumb shrinks and jumps as rows get measured, and dragging it to the middle does not land in the middle of the list.
  • Content jumps. When rows above the viewport are measured and turn out taller than guessed, everything below them, including what the user is reading, is pushed down. Chapter 6 shows how to correct for this.

So estimate close to the average, not the minimum, and per item type where you can: 48 px for a one-line system message, 140 px for a message with an image. If the server sends image dimensions, reserve the right space before the image loads, which also helps Cumulative Layout Shift (CLS).

Decision How should the list know each row's height?

Fixed height for every row

Pick one height, clip or truncate content to fit (for example one line with an ellipsis).

Pros

  • Pure math, no measuring, no jumps
  • Scroll bar is exact from the start
  • Jump to any row instantly

Cons

  • Content must fit the box
  • Wrapping text, images, and expandable rows do not fit this model

Known sizes from data

Compute each height from the data before render: item type, line count, image aspect ratio sent by the server.

Pros

  • Exact offsets without touching the DOM
  • No measuring cost while scrolling

Cons

  • Breaks when fonts, zoom, or screen width change the wrap
  • Duplicates layout logic in JavaScript

Estimate, then measure

Start with an estimated size, measure rendered rows with ResizeObserver, cache the result, correct offsets.

Pros

  • Handles any content, including images and expanding rows
  • What modern libraries do by default

Cons

  • Scroll bar and position drift until rows are measured
  • Needs scroll correction when rows above change

What we pick: Fixed heights for tables, logs, and pickers with one-line rows: fastest and simplest. Estimate-then-measure for feeds, chat, and comments. Mix in known sizes where the data gives them (image dimensions), because good estimates make every other problem smaller.

Check your understanding · Exercise 4

A chat list estimates every message at 40 px, but most messages are 90 to 150 px. What does the user notice most? Pick one.

Scroll anchoring: keeping the reader still

A user is reading message 300 in a chat. Above the viewport, row 290 gets measured and is 120 px taller than its estimate. Every row below moves down 120 px, but scrollTop has not changed, so the text being read jumps and the user loses their place.

This is the most visible bug in variable-height lists, and it has the same cause in every case: something above the viewport changed height, but scrollTop stayed the same. The fix is always the same as well: change scrollTop by the same amount, in the same frame, so the content on screen does not move.

Four things that move content

  • Measurement above the viewport. A row that was estimated is now measured, often when the user scrolls up into rows they have never seen.
  • Rows resizing. An image loads, a code block is highlighted, a reaction bar appears, a user expands a thread.
  • Prepending items. The chat loads 50 older messages above the oldest one. Without a fix, the list jumps by 50 messages' worth of height, or worse, shows the very top of the new batch.
  • New items at the top of a feed. Ten new posts arrive above the post the user is reading.

The correction

Pick an anchor: usually the first row that is visible, plus how far its top is from the viewport's top. Before the change, note the anchor's offset. After the change and before the browser paints, read the anchor's new offset and add the difference to scrollTop.

ts
// before the change
const anchor = rowAt(offsets, el.scrollTop);
const before = offsets[anchor];

applyChange(); // measure, resize, or prepend; offsets are rebuilt

// after the change, in the same frame (useLayoutEffect in React)
const after = offsets[anchorIndexAfterChange(anchor)];
el.scrollTop += after - before;
For a prepend of 50 items, the anchor's index grows by 50, so look it up by id, not by index.

Libraries do this for you in the common cases. TanStack Virtual adjusts scrollTop when a row above the viewport changes size. react-virtuoso is built around it: for chat, you tell it the index of the first item (firstItemIndex) and lower it when you prepend, and it keeps the view still. If you write the list yourself, this is the code to get right first.

The browser's own scroll anchoring

Browsers have a built-in version, also called scroll anchoring, controlled by the CSS property overflow-anchor. When content above the visible area changes size, the browser adjusts the scroll position itself. It is on by default in Chrome, Edge, and Firefox, and Safari added it only in Safari 27 (September 2026). Apps that must work on older iPhones and Macs still correct the scroll themselves.

In a virtual list the browser's anchoring can fight the library's correction: both adjust, and the content jumps the other way. So many setups turn it off on the scroll container with overflow-anchor: none and let the library do the work.

Try both below. Load older messages or let an image load in a message above the viewport, with the correction on and off, and watch where the message you were reading ends up.

Scroll correction:
A chat viewport 160 pixels tall at scrollTop 400. It shows messages 41 to 44. Message 42, the one being read, is where it was.message 41message 42message 43message 44scrollTop400message 42 stayed put
The viewport only knows scrollTop. When content above it grows, the same scrollTop points at different messages unless you add the growth to it.

You are reading message 42.

Chat: anchoring to the bottom

Chat lists are anchored at the bottom: they open at the newest message, and when a new message arrives the list should follow it, but only if the user is already at the bottom. If they scrolled up to read history, a new message must not drag them down; show a "New messages" button instead.

  • Track whether the user is at the bottom: scrollHeight - scrollTop - clientHeight under about 4 px.
  • On a new message: if at the bottom, scroll to the end after it renders; if not, keep scrollTop and show a counter.
  • On loading older messages: apply the anchor correction above, so the top of the view stays still.
  • Some apps use flex-direction: column-reverse so the bottom is the start. Most virtual list libraries assume normal direction, so check before combining them.

Check your understanding · Exercise 5

A chat prepends 50 older messages (total height 4,000 px) while the user is reading. scrollTop was 200. What should scrollTop be after the prepend to keep the view still? Pick one.

Sticky headers, grids, and other layouts

Real products are rarely a single column of rows. They have group headers that stick while you scroll, tables with frozen columns, grids of photos, and pages where the whole window scrolls rather than a box. Each changes the basic recipe a little.

Sticky group headers

A contacts list groups names under A, B, C, and the current letter sticks to the top. In a normal list, position: sticky does this for free. In a virtual list it breaks: the header for "M" is a row like any other, so when the user scrolls far enough past it, it leaves the range and is unmounted, and the sticky header vanishes.

  • Keep the active header mounted. Treat headers as rows in the data, find the last header at or above the first visible row, and always include it in the rendered set even when it is out of range. TanStack Virtual lets you do this with a custom rangeExtractor.
  • Or draw a separate overlay. Render one header element outside the list, pinned to the top, and update its text as the first visible group changes. react-virtuoso's GroupedVirtuoso handles this for you.

Grids and two-dimensional windowing

A spreadsheet with 100,000 rows and 200 columns has 20 million cells. Windowing only the rows still leaves 200 cells per row, so 30 rows is 6,000 cells. A grid virtualizer windows both directions: it computes a row range from scrollTop and a column range from scrollLeft, and renders only the cells where they cross.

A grid of 7 rows and 10 columns standing for a spreadsheet of 100,000 rows and 200 columns. A shaded block of 3 rows by 4 columns in the middle is visible. A ring of hatched overscan cells surrounds it, one row and one column on each side, making 5 rows by 6 columns rendered: 30 cells. All other cells are not in the DOM.c0c1c2c3c4c5c6c7c8c9r 0r 1r 2r 3r 4r 5r 6shaded: visible, hatched: overscan, blank: not in the DOM30 cellsof 70
The row range comes from scrollTop, the column range from scrollLeft, and only the cells where they cross are rendered. The DOM size depends on the screen in both directions.

With 30 visible rows and 12 visible columns plus overscan, the grid holds about 500 cells instead of 20 million. Frozen header rows and columns are separate, smaller virtual lists that share the scroll position. Photo grids with a fixed column count are simpler: group items into rows of 4 and virtualize the rows.

Past a certain size even windowed DOM is too slow. Google Sheets draws its grid on a canvas and keeps a small DOM layer for editing and accessibility: a big investment, worth it only at that scale.

Real tables

Absolute positioning does not mix with table layout: a tr with position: absolute loses its column widths. Either keep the table and add two spacer rows (one for the skipped rows above, one for those below), or build the table with CSS grid and ARIA table roles so rows can be placed freely.

Window scrolling and very long lists

  • The whole page scrolls. Feeds usually scroll the window, so the list reads window.scrollY minus its own distance from the top of the page. TanStack Virtual has useWindowVirtualizer; react-virtuoso has useWindowScroll.
  • The height limit. Browsers cap element height in the tens of millions of pixels. Measured limits vary by engine and version: recent tests put scroll content at about 16.7 million px in Chromium, about 17.9 million in Firefox, and about 33.5 million in Safari, so 1 million rows of 40 px (40 million px) does not fit. Huge lists scale the scroll position, but at that size search and filters beat scrolling.

Think first

A grid has 50,000 rows and 300 columns. Row virtualization alone keeps 40 rows mounted. Is that enough?

What you give up: accessibility, find-in-page, and a lighter alternative

Virtualization has a cost that does not show up in a profile. Rows that are not in the DOM do not exist for screen readers, Ctrl+F, select-all and copy, extensions, or crawlers. A good virtual list wins back what it can and is honest about the rest.

Screen readers see only rendered rows

A screen reader builds its view from the accessibility tree, which comes from the DOM. With 25 rows rendered, it announces "list, 25 items" for a list of 10,000, and the reading cursor hits the end after row 25.

ARIA lets you tell the true size and position, even though most rows are missing. Which attributes you use depends on the role.

With these, a screen reader can announce "row 302 of 10,001" even though only 25 rows exist in the DOM.
PatternOn the containerOn each rendered row
List or listboxrole="list" or role="listbox"aria-setsize="10000", aria-posinset="301" (1-based)
Table or gridrole="grid" with aria-rowcount="10001" (header row counts)aria-rowindex="302" (1-based, header is row 1)
Feed of articlesrole="feed", aria-busy="true" while loadingrole="article" with aria-setsize (or -1 if unknown) and aria-posinset
html
<div role="grid" aria-rowcount="10001" aria-label="Tickets">
  <div role="row" aria-rowindex="1">...column headers...</div>
  <!-- rows 2 to 296 are not in the DOM -->
  <div role="row" aria-rowindex="297">...</div>
  <div role="row" aria-rowindex="298">...</div>
  <!-- ... up to row 321, then nothing until 10,001 -->
</div>
Gaps in aria-rowindex tell assistive technology that rows are missing, not that the table is short.
The accessibility tree of a virtual grid. The root is a grid with aria-rowcount 10,001. Its children are the header row, row 1, then a gap for rows 2 to 296 that are not in the DOM, then rows 297 to 321, the 25 rendered rows, each with its aria-rowindex, then a gap for rows 322 to 10,001. A screen reader announces the first visible row as row 302 of 10,001.gridaria-rowcount=10001row 1headerrows 2-296not in DOMrows 297-32125 renderedrows 322+not in DOMreads: "row 302 of 10,001"
Only rendered rows exist for assistive technology. aria-rowcount on the grid and aria-rowindex on each row let a screen reader say where the user is in the full list.

Focus and the keyboard

  • Never unmount the focused row. If a keyboard user is on row 40 and scrolls away with the mouse wheel, unmounting it drops focus to the body. Keep the focused row mounted even out of range.
  • Arrow keys scroll, then focus. If the next row is not rendered, call the library's scrollToIndex, then focus it once it mounts.
  • Home and End mean the list. End scrolls to the last item, renders it, and focuses it; grids also expect Ctrl+Home and Ctrl+End.
  • Careful with aria-activedescendant. It points at an element by id, so that element must stay in the DOM.

Find in page, copy, and crawlers

Ctrl+F searches the text in the DOM, so in a virtual list it finds only the rendered rows. A user searching a 10,000 line log for "timeout" gets no results even though the word is on line 7,120. Select-all, copy, and crawlers have the same limit.

  • Offer your own search. For logs, docs, and tables, a search box that jumps to matches in the data beats Ctrl+F anyway. Code editors and log viewers sometimes catch Ctrl+F and focus their own box.
  • Offer an export ("Download CSV", "Copy all") when users need all the content at once.
  • For SEO, render the first page on the server and link to paginated URLs (?page=2) so crawlers reach every item.

The lighter alternative: content-visibility

CSS has a built-in middle ground. content-visibility: auto on each row tells the browser it may skip style, layout, and paint for that row while it is far off screen. The row stays in the DOM, so Ctrl+F, screen readers, anchor links, and select-all still work. When the user scrolls near it, the browser renders it.

css
.row {
  content-visibility: auto;
  /* size to assume while skipped; "auto" remembers the real size
     once the row has been rendered once */
  contain-intrinsic-size: auto 80px;
}

It is Baseline since September 2024, when Safari 18 added it, so you can use it in all current browsers. One caveat: Safari 18 to 25 could not find text in skipped rows with Ctrl+F; Safari 26 fixed it. contain-intrinsic-size plays the role of the estimated size: it keeps the scroll bar steady while rows are skipped.

The catch: the DOM nodes still exist. You still create all 150,000 elements, keep them in memory, and render all 10,000 components. It cuts rendering cost, not DOM cost: a great fit for articles, docs, and lists of up to a couple of thousand rows; past that, virtualize.

Check your understanding · Exercise 6

A docs site renders a 2,000-item API reference on one page. Users rely on Ctrl+F. Rendering is slow on phones. What fits best? Pick one.

Libraries, real products, and choosing an approach

A fixed-height list takes an afternoon. Variable heights, anchoring, sticky headers, window scrolling, and focus are each a week of edge cases, so teams pick a library and spend their time on the rows.

The three libraries you will hear about

  • TanStack Virtual. Headless: it computes which items to render and where, and you write the markup. Works with React, Vue, Solid, Svelte, Lit, and Angular.
  • react-window. A small React library with ready List and Grid components, strongest when sizes are fixed or known.
  • react-virtuoso. A React library that measures every item for you and builds in the hard cases: sticky groups, tables, and chat.

All three use the model from this lesson: a range from the scroll position, a size per item, and placed items inside a full-height spacer. They differ in how much they decide for you: the more they decide, the less code you write and the less control you keep over markup and accessibility.

Decision Which approach should a new long list use?

TanStack Virtual

useVirtualizer({ count, getScrollElement, estimateSize }), render getVirtualItems() yourself, attach measureElement for measured rows.

Pros

  • Full control of markup, so semantic tables and ARIA are easy
  • Any framework
  • Grids, window scrolling, custom ranges

Cons

  • You write the scroll anchoring for chat and the sticky headers yourself
  • More code per list

react-window

List with a row component and a row height, or Grid with column widths and row heights.

Pros

  • Small and simple
  • Very fast for fixed-size rows
  • Easy to read and debug

Cons

  • Measured rows (the useDynamicRowHeight hook in version 2) are slower than known sizes
  • Uses the index as the key unless you pass rowKey
  • React only

react-virtuoso

Virtuoso with data and itemContent; GroupedVirtuoso for sticky groups; TableVirtuoso for tables.

Pros

  • Variable heights with no setup
  • Chat, prepend, and stick-to-bottom built in
  • Sticky group headers built in

Cons

  • Bigger bundle
  • Less control of the DOM structure
  • React only

CSS content-visibility: auto

One CSS rule on each row plus contain-intrinsic-size; no JavaScript.

Pros

  • Ctrl+F, screen readers, and anchors keep working
  • Almost no code

Cons

  • DOM and framework cost stay: every row is still created
  • Not enough past a few thousand rows

What we pick: For a data table or picker with one-line rows, react-window or TanStack Virtual. For a chat or a feed with rich, changing rows in React, react-virtuoso saves weeks. For a design system that must control its markup or support several frameworks, TanStack Virtual. For an article or a list of a few hundred items, start with content-visibility and measure.

A decision flowchart. First: will the list pass the guideline of about 1,400 elements? If no, render every row. If yes: must users find or read everything with Ctrl+F? If yes, use content-visibility auto when the list is a few thousand rows at most; past that, virtualize and add your own search. If no: are rows the same height? If yes, virtualize with fixed sizes, for example react-window. If no, virtualize with measured sizes, for example react-virtuoso.many rows?over 1,400 elementsrender them allno extra codenoyesfind all?Ctrl+F, readingcontent-visibilityfew thousand rowsyesnosame height?one-line rowsfixed sizesreact-windowmeasured sizesreact-virtuosoyesnotoo big for content-visibility?virtualize and add your own search
Most lists end in one of the four boxes on the sides. TanStack Virtual fits either of the bottom two when you want to own the markup.

Where you see it in real products

  • Code editors. VS Code's editor (Monaco) renders only the lines in view, which is why a 100,000 line file scrolls smoothly.
  • Spreadsheets. Google Sheets draws cells on a canvas and keeps only a thin DOM layer, the far end of the same idea.
  • Chat apps. Clients load years of history in pages and keep only part of it rendered, with bottom anchoring and scroll correction.
  • Feeds. Large social feeds combine infinite scroll with windowing, so an hour-long session does not grow the DOM without limit.
  • DevTools. Chrome DevTools windows its long views, such as the console and the Network panel's request list, so a page with thousands of requests stays usable.

When not to virtualize

  • Short lists. Under a few hundred simple rows, the extra code and the lost Ctrl+F are not worth it.
  • Pages people read top to bottom, like articles and docs: use content-visibility instead.
  • When the real fix is less data. A search result page with 10,000 results nobody will scroll through needs better ranking and filters, not a virtual list.
  • Print. A virtual list prints only what is rendered. If printing matters, render a print view separately.

Check your understanding · Exercise 7

You build a customer support chat in React. Messages vary from one line to long code blocks with images; older history loads when the user scrolls up; new messages arrive live. Which choice saves the most work? Pick one.

Using it in a system design interview

Virtualization comes up in almost every design question with a list: feeds, chat, email, data tables, galleries. Interviewers want to see that you know when it is needed, what it costs, and how the hard parts work.

When to bring it up

Bring it up when you estimate the list size, right after the requirements, with the math out loud: "500 posts a session at 40 elements each is 20,000 elements, far past the 1,400 guideline, so the feed windows its rows and keeps about 20 mounted."

For short lists, say why you will not virtualize: "The autocomplete shows at most 10 results, so no windowing here." Knowing when not to use a tool is as strong a signal as knowing the tool.

What to draw

  • The three layers: the scroll container, the full-height spacer, and the small set of rendered rows with an overscan band above and below.
  • The data flow: the infinite-scroll loader fetches pages into a store; the virtualizer reads the store and renders a window of it. Two separate concerns, each with its own trigger.
  • For variable rows: the position cache (estimated, then measured heights and their offsets).
  • For chat: the anchor row and the scroll correction on prepend, plus "stick to bottom only if already at the bottom".
An interview whiteboard in two zones. Data, what is loaded: the API, a page loader using a cursor, and an item store holding 10,000 items by id. DOM, what is rendered: the scroll container reporting scrollTop and height, the virtualizer holding the range and the size cache, and about 25 mounted rows. The store feeds items to the virtualizer, the container feeds scroll position to it, and the virtualizer asks the loader for the next page when the range nears the end.Data: what is loadedDOM: what is renderedAPIpages of 50, by cursorpage loaderfetches the next pageitem store10,000 items by idscroll containerscrollTop, heightvirtualizerrange + size cacherowsabout 25 mountednear end
Two triggers, two layers: scrolling near the end loads data, any scroll moves the window. Keeping them apart is what lets you explain each one clearly.

How deep to go by level

LevelWhat a strong answer covers
JuniorRendering only visible rows, why a big DOM is slow, naming a library, stable keys
MidThe range math for fixed rows, overscan and why it exists, estimated sizes and measuring with ResizeObserver, how it fits with infinite scroll
SeniorScroll anchoring and prepend correction, keeping focused rows mounted, aria-rowcount/aria-setsize, the Ctrl+F trade-off and a custom search, content-visibility as an alternative, frame budget per scroll
StaffWhen not to virtualize, a shared list primitive for the design system, grids and canvas past the DOM's limits, measuring success with INP and long animation frames on real devices

Check your understanding · Exercise 8

In an email client design, the interviewer asks: "The inbox has 50,000 emails. How do you render it?" Which answer is strongest? Pick one.

Summary

  • Virtualization keeps all the data but only renders the rows in view plus overscan, so DOM size depends on the screen, not the list: about 30 rows for 10,000 or 1,000,000 items.
  • Every virtual list has three layers: a scroll container, a full-height spacer for the scroll bar, and a few rows placed at their true offsets.
  • Fixed heights are pure math (floor(scrollTop / rowHeight)). Variable heights need estimated sizes, measurement with ResizeObserver, and a position cache searched with binary search.
  • Overscan hides blank gaps during fast scrolling, because the compositor scrolls ahead of your JavaScript. Keep rows cheap and memoized so each frame mounts only a few.
  • When anything above the viewport changes height, correct scrollTop by the same amount before paint. Chat needs this for prepends, plus stick-to-bottom only when the user is at the bottom.
  • Unrendered rows are invisible to screen readers, Ctrl+F, copy, and crawlers. Add ARIA size and position, keep the focused row mounted, and provide your own search.
  • content-visibility: auto is the lighter option: rows stay in the DOM so find and accessibility work, but you still pay to create every element.
  • Use a library (TanStack Virtual, react-window, react-virtuoso) and spend your effort on cheap rows, good estimates, and the accessibility work.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What is list virtualization, and when would you use it?

Question 2

How do you virtualize rows whose heights you do not know in advance?

Question 3

A chat loads older messages when the user scrolls to the top, and the view jumps. Why, and how do you fix it?

Question 4

What does virtualization cost for accessibility and find-in-page, and how do you reduce it?

Question 5

How do you choose the overscan, and what happens if you get it wrong?

Practice it

These design problems lean on list virtualization.

References

Read next