Skip to content

Virtualization with TanStack Virtual

advanced12 min read

Why 10,000 Rows Kill Your App

You might think "modern browsers are fast, how bad can 10,000 rows be?" Pretty bad, actually. Every DOM node costs memory (1-2KB each). Every React component takes time to render. Mounting 10,000 rows means:

  • 10,000 DOM nodes: ~15MB of memory just for the nodes
  • 10,000 component renders: 500ms+ initial render time
  • 10,000 event listeners: If each row has click handlers
  • Scroll performance: Browser must composite all 10,000 rows on every scroll frame

The solution is beautifully simple in concept: render only what's visible. If the viewport shows 20 rows, render 20 rows. As the user scrolls, unmount rows leaving the viewport and mount rows entering it. The user sees a complete list, but only ~25 DOM nodes exist at any time.

// WITHOUT virtualization: renders all 10,000 rows
function NaiveList({ items }) {
  return (
    <div style={{ height: '600px', overflow: 'auto' }}>
      {items.map(item => (
        <Row key={item.id} item={item} />  // 10,000 DOM nodes
      ))}
    </div>
  );
}

// WITH virtualization: renders ~25 rows regardless of total
function VirtualList({ items }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <Row
            key={virtualRow.key}
            item={items[virtualRow.index]}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualRow.start}px)`,
              height: `${virtualRow.size}px`,
            }}
          />
        ))}
      </div>
    </div>
  );
}

The Mental Model

Mental Model

Think of virtualization as a theater set. A real city has thousands of buildings. But in a movie, you only need the buildings the camera can see. Set designers build facades for the visible buildings and move them as the camera pans. The audience sees a complete city, but only 10 buildings exist at any time.

The scroll container is the camera. The visible rows are the facades. As the user scrolls (camera pans), rows entering the frame are built and rows leaving are dismantled. The total height of the container matches what a full list would be (so the scrollbar looks right), but only visible rows have actual DOM nodes.

TanStack Virtual Setup

Basic List Virtualization

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

function VirtualizedList({ items }) {
  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: items.length,          // Total number of items
    getScrollElement: () => parentRef.current,  // Scroll container
    estimateSize: () => 50,       // Estimated row height in px
    overscan: 5,                  // Extra rows to render outside viewport
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Inner container: height matches total list height for scrollbar */}
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {/* Only renders visible rows + overscan */}
        {rowVirtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <Row item={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
Execution Trace
Mount
Container height: 600px, Row height: 50px
Visible rows: 600/50 = 12. With overscan 5: renders 22 rows
Layout
Total height: 10000 × 50 = 500000px
Inner div is 500,000px tall. Scrollbar shows proportional position
Scroll
User scrolls to 5000px
Virtualizer calculates: rows 100-122 are now visible
Update
Unmount rows 0-99, mount rows 112-122
Only 10 DOM operations, not 10,000. Scroll stays smooth
Memory
~25 DOM nodes at any time
vs 10,000 without virtualization. 400x less memory

Variable Height Rows

Here's where things get interesting. Real-world lists rarely have uniform row heights:

function VirtualizedChat({ messages }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,  // Best guess for initial layout
    // Measure actual size after render
    measureElement: (element) => element.getBoundingClientRect().height,
  });

  return (
    <div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            ref={virtualizer.measureElement}
            data-index={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualRow.start}px)`,
              width: '100%',
            }}
          >
            <ChatMessage message={messages[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

The measureElement callback measures each row's actual height after rendering. This handles messages of varying length, images that load asynchronously, and expandable content.

Virtual Grid

For two-dimensional virtualization (image galleries, spreadsheets):

function VirtualGrid({ items, columns }) {
  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: Math.ceil(items.length / columns),
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
  });

  const columnVirtualizer = useVirtualizer({
    horizontal: true,
    count: columns,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: `${columnVirtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map(virtualRow => (
          columnVirtualizer.getVirtualItems().map(virtualColumn => {
            const index = virtualRow.index * columns + virtualColumn.index;
            if (index >= items.length) return null;
            return (
              <div
                key={`${virtualRow.index}-${virtualColumn.index}`}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: `${virtualColumn.size}px`,
                  height: `${virtualRow.size}px`,
                  transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
                }}
              >
                <GridItem item={items[index]} />
              </div>
            );
          })
        ))}
      </div>
    </div>
  );
}
Common Trap

Virtualization breaks native browser search (Ctrl+F). Since most rows don't exist in the DOM, the browser can't find text in them. For searchable lists, combine virtualization with your own search UI, or consider whether you really need 10,000 rows (pagination might be better UX).

Also, accessibility tools (screen readers) may have difficulty with virtualized lists. Use proper ARIA attributes (role="list", role="listitem", aria-setsize, aria-posinset) to communicate the full list structure to assistive technology.

Production Scenario: The Admin Table

This is basically a rite of passage for frontend engineers. An admin panel shows a table of 50,000 log entries. Without virtualization, the page takes 8 seconds to load and eats 2GB of memory. With virtualization:

function LogTable({ logs }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: logs.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 10,
  });

  return (
    <div ref={parentRef} className="h-[80vh] overflow-auto">
      <table className="w-full">
        <thead className="sticky top-0 bg-white z-10">
          <tr>
            <th>Timestamp</th>
            <th>Level</th>
            <th>Message</th>
          </tr>
        </thead>
        <tbody>
          {/* Spacer row for total height */}
          <tr style={{ height: `${virtualizer.getTotalSize()}px` }}>
            <td colSpan={3} style={{ padding: 0 }}>
              <div style={{ position: 'relative', height: '100%' }}>
                {virtualizer.getVirtualItems().map(virtualRow => {
                  const log = logs[virtualRow.index];
                  return (
                    <div
                      key={virtualRow.key}
                      style={{
                        position: 'absolute',
                        top: 0,
                        transform: `translateY(${virtualRow.start}px)`,
                        height: `${virtualRow.size}px`,
                        display: 'flex',
                        width: '100%',
                      }}
                    >
                      <span className="w-48">{log.timestamp}</span>
                      <span className="w-24">{log.level}</span>
                      <span className="flex-1">{log.message}</span>
                    </div>
                  );
                })}
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  );
}

Results: load time drops from 8s to 100ms. Memory from 2GB to 15MB. Scroll stays at 60fps.

Common Mistakes

What developers doWhat they should do
Virtualizing lists with fewer than 100 items
100 DOM nodes are trivial for the browser. The complexity of virtualization (absolute positioning, scroll calculations, dynamic measuring) isn't worth it for small lists
Virtualization adds complexity. For short lists, just render all items
Forgetting the overscan prop
Without overscan, rows are only rendered when they enter the viewport. Fast scrolling can outpace React's render cycle, showing blank space. Overscan pre-renders rows just outside the viewport
Set overscan to 3-10 items to prevent blank flashes during fast scrolling
Using virtualization when pagination would be better UX
Virtualization breaks Ctrl+F, makes accessibility harder, and encourages endless scrolling. Pagination with URL state is often better for data tables
Consider pagination for searchable, filterable data. Virtualization for browseable, sequential data
Measuring rows synchronously in the render phase
Synchronous DOM measurement in render causes layout thrashing. TanStack Virtual's measurement system avoids this by measuring after paint
Use measureElement callback or estimateSize for initial layout, with post-render measurement

Challenge

Challenge: When to virtualize?

// For each scenario, decide: virtualize, paginate, or render all?

// Scenario 1: A dropdown with 50 options
// Scenario 2: An email inbox with 5,000 messages
// Scenario 3: A product catalog with 200 items and filtering
// Scenario 4: A chat history with 10,000 messages
// Scenario 5: A data table with 100,000 rows and sorting/filtering
Show Answer

Scenario 1: Render all. 50 items is trivial for the browser. Virtualization would add unnecessary complexity to a simple dropdown.

Scenario 2: Virtualize. Email inboxes are sequential, browseable lists. Users scroll through them linearly. 5,000 messages would be slow to render. Virtualization with variable height (messages have different lengths) is ideal.

Scenario 3: Render all (possibly paginate). 200 items is borderline but usually fine to render. If filtering reduces the list to 20-30 items, the initial 200 render is acceptable. If each item has heavy components (images, charts), consider pagination.

Scenario 4: Virtualize. Chat history is inherently sequential and scrollable. 10,000 messages with variable heights (text, images, embeds) is a classic virtualization use case. Use scrollToEnd for initial positioning and measureElement for variable heights.

Scenario 5: Paginate. 100,000 rows with sorting and filtering is best served by server-side pagination. The user can't meaningfully browse 100K rows by scrolling. Pagination with URL state lets them bookmark, share, and search specific pages. If the team insists on infinite scroll, virtualize with server-side windowing (fetch only the visible page from the server).

Quiz

Quiz
Why does virtualization use position: absolute and transform: translateY instead of normal document flow?

Key Rules

Key Rules
  1. 1Virtualization renders only visible rows (typically 20-50) regardless of total list size. 10,000 items use the same memory as 25.
  2. 2Use virtualization for lists over 500 items, or over 100 items if each row is expensive to render.
  3. 3Always set overscan (3-10 items) to prevent blank flashes during fast scrolling.
  4. 4Use transform: translateY for positioning rows — it's compositor-only and doesn't trigger layout recalculation.
  5. 5For variable-height rows, use measureElement to measure after render. Don't measure synchronously during render.
  6. 6Virtualization breaks browser search (Ctrl+F) and complicates accessibility. Add custom search UI and proper ARIA attributes.