React list virtualization: rendering 100K rows with no lag using TanStack Virtual
← Back
April 4, 2026React7 min read

React list virtualization: rendering 100K rows with no lag using TanStack Virtual

Published April 4, 20267 min read

I inherited an admin panel with a table that tried to render 50,000 log entries. The browser tab took 8 seconds to load and scrolling was 5fps. I implemented TanStack Virtual in two hours and the table now loads instantly with smooth 60fps scrolling. Here is the exact implementation, including the tricky dynamic-height rows case.

The concept

Virtualization renders only the rows currently visible in the viewport (plus a small overscan buffer). Instead of 50,000 DOM nodes, you have 20-30. When you scroll, the library repositions existing nodes with new data rather than adding new DOM elements.

Installation

bash
npm install @tanstack/react-virtual

Fixed-height rows (simplest case)

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

interface LogEntry {
  id: string;
  timestamp: string;
  level: 'info' | 'warn' | 'error';
  message: string;
}

function LogTable({ entries }: { entries: LogEntry[] }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: entries.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40, // Row height in px
    overscan: 5,            // Render 5 extra rows above/below viewport
  });

  return (
    
{/* This div establishes the full scrollable height */}
{virtualizer.getVirtualItems().map((virtualItem) => { const entry = entries[virtualItem.index]; return (
); })}
); }

Dynamic-height rows (the tricky case)

Log messages vary in length. Dynamic heights require measuring each rendered row and updating the virtualizer:

typescript
function DynamicLogTable({ entries }: { entries: LogEntry[] }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: entries.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,   // Initial estimate — will be corrected after render
    overscan: 5,
    measureElement: (element) => {
      // Called after each row renders to get the actual height
      return element.getBoundingClientRect().height;
    },
  });

  return (
    
{virtualizer.getVirtualItems().map((virtualItem) => { const entry = entries[virtualItem.index]; return (
); })}
); }

Virtualized table with sticky header

typescript
function VirtualTable({
  rows,
  columns,
  rowHeight = 40,
}: {
  rows: T[];
  columns: Column[];
  rowHeight?: number;
}) {
  const parentRef = useRef(null);
  
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => rowHeight,
  });

  return (
    
{/* Sticky header — outside the scroll container */} {columns.map((col) => ( ))}
{col.header}
{/* Scrollable body */}
{virtualizer.getVirtualItems().map((vItem) => (
{columns.map((col) => (
{col.render(rows[vItem.index])}
))}
))}
); }

Performance results

Before virtualization:
  - 50,000 rows: 8s load time, ~5fps scroll
  - DOM nodes: ~500,000

After virtualization:
  - 50,000 rows: ~200ms load time, 60fps scroll
  - DOM nodes: ~250 (25 visible + 10 overscan × ~10 cells)

The DOM node count is the key metric. Browsers handle 250 DOM nodes trivially. 500,000 nodes overwhelm the layout engine. Virtualization is not an optimization — at this scale, it is a requirement.

Share this
← All Posts7 min read