What is virtualization in the UI layer and when should you use it?
Virtualization (windowing) renders only the visible portion of a long list/grid, keeping DOM size constant regardless of dataset size. The container has a giant inner spacer for scrollbar correctness; only ~20-50 rows mount at any time. Libraries: react-window, TanStack Virtual, react-virtuoso. Trade-offs: a11y (focus, find-in-page), scroll restoration, sticky headers, dynamic item heights. Use for >1000 rows; for smaller lists it's overhead.
Virtualization (also called windowing) is the technique of rendering only the visible window of items in a long list, regardless of how many items the dataset contains.
Why
A list of 10,000 DOM nodes:
- Initial render: layout + paint for all 10k → seconds.
- Memory: tens to hundreds of MB.
- Scroll: every frame touches the whole DOM.
- Re-render: all items recompute.
Virtualized:
- DOM has ~20–50 nodes at any time.
- Memory: small + bounded.
- Scroll: only the few visible mount/unmount.
- Re-render: only visible items.
The model
The container is the viewport (fixed height, scrollable). Inside, a single inner element has the total height (items.length × itemHeight) — that makes the scrollbar match a "full" list. Inside that spacer, only the currently-visible window of items is rendered, absolutely positioned at the correct top offset.
container (height: 600px, overflow: auto)
└─ inner (height: 10000 * 50 = 500000px)
└─ row 200 at top: 10000px
└─ row 201 at top: 10050px
└─ ... (visible window only)
└─ row 211 at top: 10550pxOn scroll, compute which rows are in view, mount those, unmount the rest.
Example with TanStack Virtual
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const v = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5, // render 5 extra rows above/below for smooth scroll
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: v.getTotalSize(), position: 'relative' }}>
{v.getVirtualItems().map(row => (
<div
key={row.key}
data-index={row.index}
ref={v.measureElement} // for dynamic heights
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
}}
>
<Row item={items[row.index]} />
</div>
))}
</div>
</div>
);
}Libraries
| Library | Strengths |
|---|---|
| react-window | Small, simple, fixed and variable heights. |
| TanStack Virtual | Framework-agnostic, dynamic measurement, modern API. |
| react-virtuoso | Excellent for chat / dynamic heights / infinite scroll. |
Hard parts
Variable item heights: easiest if known upfront. Dynamic measurement (Virtuoso, TanStack) measures rows as they render and updates the spacer — the scrollbar can shift slightly during the first scroll pass.
Scroll restoration: when navigating away and back, the focused row may not be in the DOM. Persist the scroll offset (sessionStorage) and re-apply after data hydrates.
Sticky headers / group sections: most libraries support sticky rows; you mark a row as sticky and the library keeps it pinned during scroll.
Accessibility:
aria-rowcounton the container so screen readers know the total.- Focus management: if the focused row scrolls out, decide between unmounting (focus lost) or pinning (DOM kept).
- Keyboard navigation: arrow keys should scroll the list, not the page.
Find-in-page (Ctrl-F): browser's native find only searches DOM. Virtualized rows aren't findable. Workarounds: custom search UI, or render the searchable text in an off-screen visually-hidden element.
Animations: row mount/unmount can flash. Use overscan to mount before scrolling reveals the row.
Grid (2D) virtualization: harder. TanStack Virtual and react-window both support it; column virtualization is independent of row.
When to virtualize
| Dataset size | Verdict |
|---|---|
| < 100 | Plain map. Don't virtualize. |
| 100–1000 | Probably fine. Profile first. |
| 1000–10,000 | Virtualize. |
| > 10,000 | Virtualize + paginate / cursor-fetch new data. |
Pitfalls
- Mounting heavy children inside each row — every scroll mounts/unmounts → expensive. Memoize and keep row components light.
- Forgetting
overscan— rows pop in visibly during fast scrolls. - Mismatched
estimateSizevs actual — scrollbar jumps. - Lost focus when the focused row unmounts.
- Trying to use Ctrl-F in production and finding only visible rows.
- Stacking virtualization inside virtualization (rare but possible) — gets weird.
When not
- Small lists.
- Lists where every row needs SEO indexing (Google crawler won't run scroll JS).
- Lists where Ctrl-F is critical (settings list, command palette).
Mental model
Virtualization keeps DOM size constant. Pay a small complexity cost (library, a11y considerations, scroll restoration) to make 100k-row lists smooth. It's the standard solution for "scale" lists in modern apps.
Follow-up questions
- •How do you handle variable item heights?
- •What are the a11y implications of virtualization?
- •How do you restore scroll position on back-navigation?
- •When does virtualization break down (and what to use instead)?
Common mistakes
- •Virtualizing tiny lists — added complexity for no win.
- •No overscan — visible row pop-in.
- •Heavy children inside row — mount/unmount is expensive.
- •Mismatched estimateSize and actual heights — jumpy scrollbar.
- •Losing focus when the focused row unmounts.
- •Forgetting Ctrl-F doesn't work — UX surprise.
Performance considerations
- •Virtualization is one of the highest-ROI optimizations for long-list UIs. Memory drops from hundreds of MB to a few; scroll FPS hits 60 even on slow devices; CPU during scroll is constant. Cost is library + complexity. Net win for any list >1000 items.
Edge cases
- •Window resize → re-measure heights.
- •RTL languages flip horizontal virtualization math.
- •Sticky group headers within virtualized list — library-specific.
- •Items containing iframes / media — pin to avoid mount/unmount thrash.
- •SSR: render an estimated viewport on the server, hydrate on the client.
Real-world examples
- •Slack message list, Gmail inbox, Spotify track list.
- •Twitter / X timeline (infinite + virtualized).
- •Notion / Airtable databases.
- •Spreadsheets — 2D virtualization for both rows and columns.