React list virtualization: rendering 100K rows with no lag using TanStack Virtual
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
npm install @tanstack/react-virtual
Fixed-height rows (simplest case)
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:
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
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.