When would you use virtualization?
When the list is long enough (≈hundreds of rows) that DOM nodes alone hurt — measure first. Virtualization renders only visible rows + overscan, trading complexity for memory and render time.
Virtualization (a.k.a. windowing) is the technique of rendering only the slice of a long list that's currently visible (plus a small overscan buffer above and below), and faking the total scroll height with an empty spacer. Even if the data has 1,000,000 rows, the DOM might hold 30 — react renders quickly, scrolling stays at 60fps, memory stays flat. It's a classic time/space trade: more code complexity in exchange for orders-of-magnitude better rendering performance.
The mechanics. A virtualizer needs three things: (1) total item count, (2) a way to know each row's height (fixed, estimated, or measured on render), (3) the scroll container's current scroll offset. From that it computes a visible range (startIndex, endIndex) and renders just those rows, positioned absolutely (or with translate transforms) inside a parent whose height equals the sum of all row heights. A spacer/relative offset keeps the scrollbar honest.
Use it when:
- The list has enough items that DOM nodes alone are the cost — typically several hundred non-trivial rows (images, multi-line text, tooltips, interactive controls). The threshold is "render time + GC time > frame budget."
- Tables with many rows × many columns — each cell is a DOM node, so cost scales as the product.
- Chat / log / feed viewers with append-heavy data — without virtualization, scrollback eventually freezes the tab.
- Trees with thousands of nodes (file explorers, org charts, JSON viewers).
- You can measure a real problem: scroll FPS <60, INP >200ms, a long task in the Performance panel.
Don't use it when:
- The list has <100 trivial items — the windowing overhead (math, refs, measurement) is more than the savings.
- Item heights are highly variable and hard to estimate, and your UX has lots of jump-to-anchor flows — measurement jitter causes scroll jumps that frustrate users.
- The content must be crawlable (SEO, Ctrl-F find-in-page). Crawlers and the browser's find feature only see what's in the DOM. Pagination or server-rendered chunks may be a better fit.
- The user expects predictable Ctrl-F or Tab key navigation across the entire list.
Libraries:
@tanstack/react-virtual— modern, headless, supports dynamic sizes via measurement, works with any layout including grids. The default choice in 2025.react-window— simple, smaller, fixed-size lists or fixed-grid; fine for most basic cases.react-virtualized— older, heavier; superseded by react-window from the same author.@tanstack/react-table+ virtual — sortable / filterable / virtualized tables.react-arborist— virtualized trees with drag/drop.
Common pitfalls:
- Accessibility regressions. Screen readers and assistive tech don't see off-screen rows. Use
role="grid"+aria-rowcount+aria-rowindexso the AT knows the true size. Make sure keyboard navigation (Home/End, arrow keys) scrolls the right row into view and restores focus. - Find-in-page (Ctrl-F). Browsers only search rendered text. Provide an in-app search/filter so users aren't stranded.
- Anchor links and
#hash.location.hash = "#row-2003"won't find an unmounted row. Implement a manual scroll-to-index handler. - Sticky headers / footers. Need explicit support from the library, otherwise they vanish when the windowed range scrolls past.
- Variable heights. Naïve approaches re-measure rows on each render, causing scrollbar jitter. Use a measurement cache keyed by item id;
@tanstack/react-virtual'smeasureElementhandles this. - Resize. When the container resizes (zoom, sidebar collapse), all measurements may need to be invalidated.
- Interaction state across mount/unmount. A row that unmounts loses local state (open menu, focus). Lift state up, or use overscan and stable keys to keep state alive while scrolling.
Alternatives to virtualization:
- Pagination (server-side or client-side) — simpler, crawlable, fewer accessibility hazards. The right answer for most product UIs.
- Infinite scroll with pagination — load 50 at a time on scroll; only renders what the user has seen. Keeps DOM bounded if you also unmount very-far-up rows.
- Server-side cursors + lazy windows — for truly infinite data (timelines, search results), combine pagination with virtualization just inside the loaded set.
Decision flow: is the list >500 non-trivial rows AND can't be paginated AND is causing measurable jank? → virtualize. Otherwise → paginate. Don't reach for windowing as the default; reach for it when DevTools tells you to.
Code
Follow-up questions
- •How do you make virtualized lists accessible?
- •When is pagination a better choice?
- •How do variable-height rows complicate virtualization?
Common mistakes
- •Virtualizing tiny lists.
- •Skipping `key` correctness — windowing reuses DOM nodes.
- •Breaking Ctrl-F discoverability.
Performance considerations
- •Memory drops dramatically — DOM node count is the dominant cost.
- •Scroll FPS improves when paint cost per frame drops.
Edge cases
- •Sticky headers, drag-reorder, and keyboard nav across windowed rows are non-trivial.
Real-world examples
- •Linear's issue list, GitHub's file tree, Notion's database views.