Back to Performance
Performance
medium
mid

When should you reach for virtualization in a frontend application?

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.

7 min read·~12 min to think through

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-rowindex so 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's measureElement handles 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

tsx
import { useVirtualizer } from "@tanstack/react-virtual";

function Rows({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const v = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 6,
  });
  return (
    <div ref={parentRef} className="h-96 overflow-auto">
      <div style={{ height: v.getTotalSize(), position: "relative" }}>
        {v.getVirtualItems().map((row) => (
          <div
            key={row.index}
            style={{
              position: "absolute",
              top: 0, left: 0, width: "100%",
              transform: `translateY(${row.start}px)`,
              height: row.size,
            }}
          >
            {items[row.index]}
          </div>
        ))}
      </div>
    </div>
  );
}
TanStack Virtual minimal example

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.

Related questions