A list of 20,000 rows costs 20,000 DOM nodes, and the browser pays for every one in layout, style recalculation and memory long after React 7,897 has finished. Windowing — virtualization — renders only the rows inside the scrollport plus a small overscan, translating them into position as you scroll. react-window 17,208 (https://github.com/bvaughn/react-window 17,208 ) 2.3.1 (MIT, npm 2,036 i react-window) is the smallest option; its version 2 API takes a rowComponent instead of a render-prop child.
<b>plain</b><div id="plain"></div><b>windowed</b><div id="win"></div><p id="out"></p>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react/jsx-runtime": "https://esm.sh/react@19.3.0/jsx-runtime",
"react-dom": "https://esm.sh/react-dom@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client",
"react-window": "https://esm.sh/react-window@2.3.1?external=react" } }</script>
<script type="text/babel" data-type="module">
import React from 'react';
import { createRoot } from 'react-dom/client'; import { flushSync } from 'react-dom';
import { List } from 'react-window';
const N = 20000, box = { height: 40, font: '13px system-ui' };
const Row = ({ index, style }) => <div style={style}>customer {index}</div>;
const time = (id, el) => { const root = createRoot(document.getElementById(id));
const t = performance.now(); flushSync(() => root.render(el));
return (performance.now() - t).toFixed(1); };
const b = time('win', <List rowComponent={Row} rowCount={N} rowHeight={18}
rowProps={{}} style={box} />);
const a = time('plain', <div style={{ ...box, overflow: 'auto' }}>
{Array.from({ length: N }, (_, i) => <div key={i}>customer {i}</div>)}</div>);
const n = (id) => document.querySelectorAll('#' + id + ' *').length;
out.innerHTML = `plain ${a} ms / ${n('plain')} nodes, windowed ${b} ms / ${n('win')} nodes`;
</script>
The windowed mount is about four times faster, but the node counts differ by three orders of magnitude, and that is what keeps mattering: every later scroll, resize and style change walks the smaller tree. Note the measurement order — mounting the windowed list after the 20,000-node one forces a full-document layout and inflates its time several-fold, a benchmark artifact rather than the library's doing.
Windowing has real costs: Ctrl+F finds nothing that is not rendered, and variable row heights need a measurement pass (useDynamicRowHeight). Accessibility is handled for you — spread the ariaAttributes prop List hands each row onto your row element. TanStack Virtual 79,069 is the headless alternative, react-virtuoso adds dynamic heights and infinite scroll, and react-virtualized is in maintenance mode.