List Virtualization: The Concept
Render only the rows on screen so a 100,000-item list stays smooth.
The fastest way to render a huge list is to not render most of it. Virtualization (also called windowing) keeps in the DOM only the handful of rows currently visible, plus a small buffer above and below. Scroll, and it swaps rows in and out. A list of 100,000 items then costs about the same as a list of 20.
Why the naive list dies
Mapping 100,000 items to 100,000 DOM nodes creates enormous layout, paint, and memory pressure — and every re-render must reconcile all of them. Browsers choke well before that. Pagination avoids it but changes the UX; virtualization keeps the infinite-scroll feel while paying only for what is seen.
How windowing works, conceptually
- Measure the scroll container's height and the (fixed or estimated) row height.
- From
scrollTop, compute the first and last visible index. - Render only that slice, absolutely positioned at the right offset.
- Give the container a total height (rows x rowHeight) so the scrollbar reflects the full list.
const first = Math.floor(scrollTop / rowHeight);
const count = Math.ceil(viewportHeight / rowHeight) + overscan;
const visible = items.slice(first, first + count);Use a library, understand the idea
Rolling your own gets tricky fast with variable row heights, sticky headers, and horizontal grids. Reach for @tanstack/react-virtual or react-window — they are tiny and battle-tested. Your job is to recognize when a list needs windowing (hundreds to thousands of rows, or heavy rows) and to keep row components cheap and memo-friendly so recycling stays fast.
Example
import { useState, useRef } from 'react';
// A minimal fixed-height virtual list to show the core idea.
function VirtualList({ items, rowHeight = 32, height = 320, overscan = 4 }) {
const [scrollTop, setScrollTop] = useState(0);
const total = items.length * rowHeight;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const visibleCount = Math.ceil(height / rowHeight) + overscan * 2;
const slice = items.slice(first, first + visibleCount);
return (
<div
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
style={{ height, overflow: 'auto', position: 'relative' }}
>
{/* Spacer gives the scrollbar the full height. */}
<div style={{ height: total }}>
{slice.map((item, i) => {
const index = first + i;
return (
<div
key={item.id}
style={{
position: 'absolute',
top: index * rowHeight,
height: rowHeight,
}}
>
{item.label}
</div>
);
})}
</div>
</div>
);
}
// In production, prefer @tanstack/react-virtual for variable heights & grids.When to use it
- An activity feed displaying 50,000 log entries renders smoothly at 60 fps with react-window because only the ~20 visible rows are in the DOM at any time.
- A spreadsheet-style table with 10,000 rows and 30 columns uses windowing so initial paint takes milliseconds instead of freezing the browser tab.
- A social media timeline with complex card components per post uses virtualization to avoid mounting thousands of cards up-front, keeping memory usage constant as the list grows.
More examples
FixedSizeList with react-window
FixedSizeList only renders the rows that fit in the 600 px viewport plus a small buffer, so 100,000 items cost the same as rendering 20.
import { FixedSizeList } from 'react-window';
function Row({ index, style }) {
return (
<div style={style} className="list-row">
Item #{index}
</div>
);
}
function VirtualList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={48}
width="100%"
>
{Row}
</FixedSizeList>
);
}Passing data to virtualised rows
Passing the array via itemData avoids closure-per-row: every row reads from the same stable reference, preventing unnecessary re-renders from new function instances.
import { FixedSizeList } from 'react-window';
function UserRow({ index, style, data }) {
const user = data[index];
return (
<div style={style} className="user-row">
<img src={user.avatar} alt="" />
<span>{user.name}</span>
</div>
);
}
function UserList({ users }) {
return (
<FixedSizeList
height={500}
itemCount={users.length}
itemSize={56}
itemData={users}
width={400}
>
{UserRow}
</FixedSizeList>
);
}Variable-height list with VariableSizeList
VariableSizeList accepts an itemSize function so each row can have a different height, handling mixed content types like headers and body rows.
import { VariableSizeList } from 'react-window';
const rowHeights = items.map((item) =>
item.type === 'header' ? 80 : 48
);
function VarList() {
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={(index) => rowHeights[index]}
width="100%"
>
{({ index, style }) => (
<div style={style}>{items[index].text}</div>
)}
</VariableSizeList>
);
}
Discussion