Skip to main content

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

jsx
// 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 added

The 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 RenderVirtualized
DOM nodes (10k items)10,00020-50, reused
Scroll at 60fpsNoYes
Memory (10k items)100MB+~2MB
Initial load2s+~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.

jsx
// 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.

jsx
// 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.

jsx
// 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-list internally for enterprise dashboards.
  • Vercel admin panels virtualize log output with infinite scroll.
  • For new projects, prefer @tanstack/react-virtual (actively maintained, ~5kb) over react-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

jsx
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 position

Each 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

jsx
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 issues

The 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?