What is virtualization and why is it needed
Virtualization renders only the DOM nodes visible in the viewport, recycling a fixed pool of ~20-50 elements as the user scrolls, regardless of how many items the list actually contains.
Theory
TL;DR
- Analogy: a theater with 20 seats where actors swap through side doors as the spotlight moves. You never build 10,000 seats.
- Full render: 10,000 DOM nodes for 10,000 items. Virtualized: 20-50 nodes, reused on scroll.
- Memory drops from 100MB+ to ~2MB for the same dataset.
- Initial load goes from 2s+ to ~100ms.
- Use it when a scrollable list exceeds 500 items and scroll lags; skip it for short static lists.
Quick Example
// Without virtualization: 10k DOM nodes, browser struggles on scroll
const SlowList = ({ items }) => (
<ul style={{ height: '400px', overflow: 'auto' }}>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
// With react-window: ~20 nodes regardless of items.length
import { FixedSizeList as List } from 'react-window';
const FastList = ({ items }) => (
<List height={400} itemCount={items.length} itemSize={35}>
{({ index, style }) => <div style={style}>{items[index].name}</div>}
</List>
);
// Open DevTools during scroll: same ~20 divs repositioned, not new ones addedThe library positions each visible row with transform: translateY(), so the browser skips reflow entirely and holds 60fps through the whole scroll.
How the DOM Pool Works
The browser figures out which rows fit the viewport using getBoundingClientRect() or ResizeObserver. The library keeps a small buffer (called overscan) of extra rows above and below the visible area, typically 3-5 rows. When the user scrolls, existing DOM nodes get new content and a new translateY offset. No nodes are created or destroyed mid-scroll.
React reconciles via the key on each virtual item. Skip the key and React re-renders every row on every scroll event. That one omission drops frame rate from 60fps to 10fps, and it shows up in code reviews more often than you'd expect.
When to Use
- List or table with more than 500 items -> virtualize.
- Infinite scroll feed (email inbox, social timeline) -> load chunks and virtualize the visible slice.
- Nested tree structures like a file explorer -> virtualize only expanded branches.
- Mobile app with a scrollable list over 200 items -> always (memory is tighter on phones).
- Static list under 200 items -> skip it. The library adds ~10kb for no measurable gain.
Comparison Table
| Full Render | Virtualized | |
|---|---|---|
| DOM nodes (10k items) | 10,000 | 20-50, reused |
| Scroll at 60fps | No | Yes |
| Memory (10k items) | 100MB+ | ~2MB |
| Initial load | 2s+ | ~100ms |
| Best for | <500 static items | >500 dynamic or scrollable lists |
Common Mistakes
Missing key on virtual items. React needs a stable key to track which item maps to which DOM node. The list looks fine in development, but drops to 10fps in production when data updates often.
// Wrong - triggers full re-render on every scroll event
{virtualItems.map(item => <div>{data[item.index]}</div>)}
// Right
{virtualItems.map(item => <div key={item.key}>{data[item.index]}</div>)}Zero overscan on fast-scrolling lists. Flicking the list produces white gaps where rows should be.
// Wrong - user sees blank space during fast fling
const virtualizer = useVirtualizer({ count: items.length, overscan: 0 });
// Right - render 5 extra rows above and below the viewport
const virtualizer = useVirtualizer({ count: items.length, overscan: 5 });Fixed row height for variable-height content. Chat messages with images break layout when every row is assumed to be 50px.
// Wrong - gaps and misaligned rows with emojis or images
const virtualizer = useVirtualizer({ estimateSize: () => 50 });
// Right - measure real heights at runtime
const virtualizer = useVirtualizer({
count: messages.length,
estimateSize: () => 50,
measureElement: el => el?.getBoundingClientRect()?.height || 50,
});Virtualizing small lists. Adding react-window for 150 items ships 10kb of JS for no gain. Use .map().
Nested virtualization. A virtual list inside a virtual table causes double translateY transforms that overflow and break positioning. Flatten the structure or use position: sticky for headers instead.
Real-World Usage
- Jira and Gmail web use virtualization for issue lists and inbox with 10,000+ rows.
- TanStack Table v8+ ships with virtual row support via
@tanstack/react-virtual. - Ant Design Table uses
vc-virtual-listinternally for enterprise dashboards. - Vercel admin panels virtualize log output with infinite scroll.
- For new projects, prefer
@tanstack/react-virtual(actively maintained, ~5kb) overreact-virtualized(legacy, heavier).
Follow-up Questions
Q: What is the difference between virtualization and windowing?
A: Same concept, different name. "Windowing" was the older term from react-virtualized; the community now uses "virtualization".
Q: How does react-window handle variable row heights?
A: Use VariableSizeList and pass a getItemSize(index) function. It caches each measured height and recalculates scroll offsets when the container resizes.
Q: How does React 18 affect virtualization performance?
A: startTransition wraps scroll handler updates so they run as non-urgent transitions. Scroll jank disappears even when rows contain expensive components, because React can interrupt the update for higher-priority input events.
Q: Why not use CSS contain: layout instead of removing DOM nodes?
A: contain tells the browser to skip style recalculations outside the container, but the nodes stay in the DOM. For 50,000 rows, memory is still blown. Virtualization removes the nodes entirely.
Q (senior): ResizeObserver breaks virtualization in Safari 15-16. How do you fix it without switching libraries?
A: Polyfill with MutationObserver on the scroll container, throttle the scroll event handler to 16ms intervals, and add window.resize as a fallback. This specific bug comes up in FAANG mobile interviews.
Examples
Basic: Fixed Row Height with react-window
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>User #{index}</div>
);
function UserList({ users }) {
return (
<List height={400} itemCount={users.length} itemSize={35} width="100%">
{Row}
</List>
);
}
// DOM holds ~20 rows at any scroll positionEach row is 35px tall. The itemSize prop must match the actual CSS height or scroll position drifts. Open DevTools during scroll and you will see the same ~20 divs getting repositioned, not destroyed and recreated.
Production: Dynamic List with TanStack Virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
function IssueList({ issues }) { // e.g. 50k issues from API
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: issues.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(item => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${item.start}px)`,
width: '100%',
}}
>
{issues[item.index].title}
</div>
))}
</div>
</div>
);
}
// ~30 rows in DOM while scrolling through 50k issuesThe outer div gets the full virtual height so the scrollbar accurately reflects list size. Each visible row is positioned with translateY, which skips layout recalculation. This is the same pattern TanStack Table v8 and Shadcn dashboards use in production.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.