Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~23 min· topic 9 of 10

Topic 16.9

Big Lists and Virtualisation: the Performance View

In one line

Rendering thousands of rows is slow because every row costs React work and DOM nodes the browser must lay out and paint; windowing (virtualisation) renders only the rows in view plus a few extra, libraries like TanStack Virtual and react-window do it for you, and CSS content-visibility is a lighter alternative for long pages.

Think of it like this

A long train passing a station window. You only ever see the four or five carriages in front of the window, never the whole train at once. A virtualised list works like that window: there may be 10,000 rows, but only the 15 you can see actually exist on the page, and as you scroll, rows are swapped in and out.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Viewport
The visible area of a scroll container: the part of the list you can see right now.
Windowing / virtualisation
Rendering only the list rows inside the viewport, plus a few extra, and swapping them as you scroll.
Overscan
Extra rows rendered just outside the viewport so fast scrolling doesn't show gaps.
Spacer
An empty element as tall as the whole list, so the scrollbar has the right size.
DOM node
One element or text node in the page. More nodes mean more memory, layout and paint work.
content-visibility
A CSS property that lets the browser skip rendering off-screen elements while keeping them in the page.
Pagination
Showing a long list in pages (1–50, 51–100...) instead of all at once.

Step by step

01Measure the naive list

Render all rows and count: how many DOM nodes, how long the first render takes in a production build, and how many row renders a single click causes. In the first live example one click renders all 5,000 rows, because no row is memoised.

02Make rows cheap first

Memoise the row (or use the compiler) and keep props stable. Now a click renders only the rows whose props changed. The DOM is still huge, though, so the first render and memory stay heavy.

Row.jsxwhole filejsx
const Row = memo(function Row({ name, selected }) {
  return <li className={selected ? "row selected" : "row"}>{name}</li>;
});

03Window the list

Compute the visible slice from scrollTop, the row height and the viewport height. Render only those rows, absolutely positioned inside a full-height spacer.

Window the listdiagram
Rendering diagram…

04Use a library for real lists

TanStack Virtual handles measuring, dynamic heights and scrolling to an index. You render the items it gives you.

Orders.jsxwhole filejsx
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

function Orders({ rows }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 30,
    overscan: 5,
  });
  return (
    <div ref={parentRef} style={{ height: 240, overflowY: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            style={{ position: "absolute", top: 0, left: 0, right: 0, height: item.size, transform: "translateY(" + item.start + "px)" }}
          >
            {rows[item.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

05Or let CSS skip off-screen work

For long pages of content blocks (not tens of thousands of rows), content-visibility: auto on each block gives much of the rendering win without changing any React code.

styles.csswhole filecss
.card {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px; /* estimated height until rendered */
}

Try it yourself

  1. 1

    Memoise the rows

    In the naive list, wrap Row in memo (import it from react). Predict the number of row renders for one click. Only the rows whose selected prop changed render: 1 the first time, 2 after that (the old and the new selection).

  2. 2

    Change the overscan

    In the windowed list, set OVERSCAN to 0, then to 10. Predict the row counts in both log lines. Scroll fast by hand with each setting: with 0 you may glimpse blank space.

  3. 3

    Find-in-page

    In the windowed list, use your browser's find (Ctrl+F) to search for "Order #9000". It isn't found until you scroll there. That's the accessibility and UX cost of windowing.

Code & diagrams

The naive list: everything rendered, every time New tab

5,000 rows exist in the DOM and one click re-renders all of them, though only one row's `selected` changed. The rows already use `content-visibility: auto`, so the browser skips layout and paint for off-screen ones, but React still does all the work. Try wrapping Row in `memo`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Rows in the DOM: 5000
Row renders for one click: 5000
A windowed list: 10,000 rows, about 15 in the DOM New tab

Scroll it yourself: the scrollbar is the size of 10,000 rows, but only the visible rows plus 3 overscan rows on each side exist. At the top there's no overscan above row 1, so there are 12 rows; in the middle there are 15. (`key={i}` is fine here: the index is the row's identity in this fixed list.)

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

At the top: 12 rows in the DOM, Order #1 to Order #12
After scrolling: 15 rows in the DOM, Order #4998 to Order #5012

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Rows with variable heights in a fixed-height window

In the windowed list, make every tenth row taller: height: i % 10 === 0 ? 60 : 30, keeping top: i * ROW_HEIGHT.

terminal
$ Preview
── what you'll see ──
Tall rows overlap the rows below them, and text is cut off or covered.

Myth vs fact

Myth

React's virtual DOM is the same thing as a virtualised list.

Fact

The virtual DOM is React's element tree for the whole UI. Virtualising a list means not rendering off-screen rows at all. They're unrelated.

Myth

Every long list needs virtualisation.

Fact

A few hundred simple rows are usually fine, especially with memoised rows. Measure first; pagination or content-visibility may be enough.

Myth

content-visibility removes elements from the DOM.

Fact

They stay in the DOM (and in find-in-page and the accessibility tree). The browser only skips their layout and paint while off-screen.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    For dynamic heights, virtualisers keep an array of measured sizes and their prefix sums, find the first visible row by binary search (see /dsa binary search), and correct the scroll position when a measured row above the viewport changes size, so content doesn't jump.

  • ▸

    Fast scrolling fires many scroll events. Updating state on each is fine in practice because only ~15 rows render; some libraries also use transform: translateY instead of top so rows move on the compositor without triggering layout.

  • ▸

    Virtualised tables need ARIA: role="grid" or table with aria-rowcount on the container and aria-rowindex on each row, so assistive technology announces "row 4,998 of 10,000".

  • ▸

    In React 19.2, content-visibility pairs well with <Activity> (Topic 16.10) for long tabbed pages: hidden tabs keep their state while the browser skips their rendering.

Remember this

  1. 1

    Why big lists are slow. Each row costs three times. (1) React work: calling the row component and reconciling it, on the first render and every time the list re-renders. (2) DOM nodes: each row creates elements the browser must store; 10,000 rows with 5 elements each is 50,000 nodes. (3) Layout and paint: the browser computes the size and position of every node, and repeats much of it when something changes. Lighthouse warns when a page has more than about 1,400 DOM nodes. Memory use grows too, which hurts on phones.

  2. 2

    First, fix the cheap things. Use stable keys from your data (Topic 16.2). Keep each row cheap and memoised (or compiled) so selecting one row doesn't re-render the other 4,999. Don't copy or sort the full list on every render: cache it with useMemo (Topic 16.4). Ask whether the user needs 10,000 rows at all: pagination (pages of 50) or infinite loading (load more near the bottom) often solve it with better UX.

  3. 3

    Windowing, also called virtualisation, renders only the rows inside the visible area (the viewport of the scroll container) plus a small overscan (a few rows above and below, so fast scrolling doesn't show blank space). A tall empty spacer keeps the scrollbar the right size, and each visible row is positioned at its real offset (top = index × rowHeight). On every scroll event, the component computes the first and last visible index and renders only that slice. React work and DOM size become constant (about 15–30 rows), no matter how long the list is.

  4. 4

    Libraries. Real lists have variable row heights, horizontal scrolling, grids, sticky headers, scroll restoration and keyboard focus. Use a library: TanStack Virtual (@tanstack/react-virtual, the useVirtualizer hook; headless, measures dynamic heights) or react-window (List and Grid components, small and simple). Both follow the same idea as the hand-written demo below. Topic 24.1 builds a complete virtualised list step by step.

  5. 5

    Costs of virtualisation. Rows that aren't rendered don't exist in the DOM: the browser's find-in-page (Ctrl+F) can't find them, screen readers see only part of the list (set aria-rowcount/aria-setsize and aria-posinset so they know the full size), and anchor links to off-screen rows don't work. Rows are unmounted when scrolled away, so their state (an open menu, typed text) is lost unless it lives above the list.

  6. 6

    **content-visibility: auto is a CSS property (see /css) that tells the browser to skip layout and paint** for elements that are off-screen while keeping them in the DOM. Pair it with contain-intrinsic-size (an estimated size, such as auto 40px) so the scrollbar doesn't jump. It doesn't reduce React work or the number of DOM nodes, but it can cut rendering time a lot for long pages of sections or cards, keeps find-in-page and accessibility working, and needs no JavaScript. Measure the win in each case: compare the Performance panel recording (Topic 16.6) before and after, looking at scripting time and rendering (layout/paint) time separately.

Explain it without notes

01

Why is rendering 10,000 list rows slow? Name the different costs.

02

How does windowing work?

03

What are the downsides of virtualising a list?

04

When would you choose content-visibility: auto over a virtualisation library?

Practice

01

A product page renders 300 product cards in a long scrolling page and scrolling feels janky. Suggest a no-JavaScript improvement and show the CSS.

02

Given a fixed row height of 40px, a viewport of 400px and overscan 2, which rows render when scrollTop is 2,000?

Trade-offs

  • ↔

    Windowing makes huge lists fast and light, but costs find-in-page, simpler accessibility, row state on scroll-out, and extra code for variable heights.

  • ↔

    content-visibility keeps everything in the DOM and accessible, but only saves rendering work, not React work or memory.

Done when you can

  • I can explain the three costs of a big list.

  • I can make rows cheap with stable keys and memoisation.

  • I can explain and build a simple fixed-height windowed list.

  • I know when to reach for TanStack Virtual or react-window.

  • I can use content-visibility and measure the difference.