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.
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.
04Use a library for real lists
TanStack Virtual handles measuring, dynamic heights and scrolling to an index. You render the items it gives you.
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.
.card {
content-visibility: auto;
contain-intrinsic-size: auto 120px; /* estimated height until rendered */
}Try it yourself
- 1
Memoise the rows
In the naive list, wrap
Rowinmemo(import it fromreact). Predict the number of row renders for one click. Only the rows whoseselectedprop changed render: 1 the first time, 2 after that (the old and the new selection). - 2
Change the overscan
In the windowed list, set
OVERSCANto 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
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
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
Console
Nothing logged yet.Expected console output
Rows in the DOM: 5000
Row renders for one click: 5000Scroll 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
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 #5012Break 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.
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: translateYinstead oftopso rows move on the compositor without triggering layout. - ▸
Virtualised tables need ARIA:
role="grid"ortablewitharia-rowcounton the container andaria-rowindexon each row, so assistive technology announces "row 4,998 of 10,000". - ▸
In React 19.2,
content-visibilitypairs well with<Activity>(Topic 16.10) for long tabbed pages: hidden tabs keep their state while the browser skips their rendering.
Remember this
- 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
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 withuseMemo(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
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
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, theuseVirtualizerhook; headless, measures dynamic heights) or react-window (ListandGridcomponents, 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
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-setsizeandaria-posinsetso 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
**
content-visibility: autois 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 withcontain-intrinsic-size(an estimated size, such asauto 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
Why is rendering 10,000 list rows slow? Name the different costs.
How does windowing work?
What are the downsides of virtualising a list?
When would you choose content-visibility: auto over a virtualisation library?
Practice
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.
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-visibilitykeeps 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.