Your Table Has 10,000 Rows. Your Browser Has One DOM. Let's Talk About Virtualization.
When to virtualize, when pagination is enough, which library to pick, and what breaks when you get it wrong

Rendering a table with 10,000 rows in React is not a performance problem. Its a DOM problem.
React can handle the state and reconciliation just fine. The browser is the bottleneck. Every row you put in the DOM is a layout node the browser has to measure, paint, and keep in memory. At a few hundred rows you start noticing lag. At a few thousand, scrolling gets janky. At ten thousand, you might just crash the tab on a mid-range phone.
Virtualization is the standard fix. Instead of rendering all 10,000 rows, you render only the ones visible on screen plus a small buffer, and swap them in and out as the user scrolls. The DOM stays small, the scroll feels native, and your users don't file support tickets.
But virtualization is not free. It adds complexity, has real tradeoffs, and is completely unnecessary for most tables most people build. This post is about understanding when you actually need it, how to implement it properly, and what alternatives exist when you don't.
What Virtualization Actually Does
The concept is simple. You have a scrollable container with a fixed height. Instead of rendering every row inside it, you:
Calculate which rows are currently visible based on scroll position
Render only those rows plus an overscan buffer above and below
Use absolute positioning or padding to make the scrollable area feel like it contains all the rows
As the user scrolls, swap out rows that leave the viewport and render the ones coming in
The DOM stays at roughly (visible rows + overscan) * 2 nodes regardless of how many total rows you have. A table with 50,000 rows might only ever have 30-40 actual DOM nodes at any time.
Total rows: 50,000
Visible at once: ~20
Overscan above/below: 5 each
Actual DOM nodes: ~30
Without virtualization DOM nodes: 50,000
The tradeoff is that virtualization requires rows to have predictable positions. Either every row is fixed height (easy), or you measure each row dynamically as it renders (complex). Most complexity in virtualizing a table comes from variable row heights.
When You Actually Need It
Before looking at any library, be honest about whether you have a real problem. The only valid reason to add virtualization is that you've measured a performance issue and the data volume is the cause.
Concrete cases where virtualization is the right call:
Long scrolling data feeds. Log viewers, audit trails, activity streams, transaction histories. Data that can run into tens of thousands of rows and doesn't map well to pagination because users need to scroll continuously.
Large dataset tables with no server-side pagination. If the API returns everything up front (legacy APIs, CSV imports, offline-first apps), and the dataset is consistently over a few hundred rows, virtualization saves you.
Low-powered devices with complex rows. A row with avatars, badges, action buttons, and multiple text fields is expensive to paint. On mobile or older hardware, 300 complex rows can be enough to cause issues.
Real-time data tables. Financial dashboards, monitoring UIs, live feeds. When rows update frequently and you have a lot of them, keeping thousands of reactive DOM nodes alive gets expensive fast.
Here's a quick sanity check. Open your table in Chrome DevTools, go to the Performance tab, and record a scroll interaction. If the frames drop below 60fps and the flame chart shows layout and paint as the culprits, you have a DOM problem that virtualization solves. If frames are fine, close the tab and go build something else.
When Its Overkill
Virtualization is overkill in more situations than developers tend to assume.
Paginated tables
If you already paginate (show 25, 50, or 100 rows per page), you've already solved the DOM problem. Virtualizing a paginated table is redundant. Pick one strategy, not both.
Tables under 200-300 rows
Modern browsers handle a few hundred simple rows without breaking a sweat. The performance overhead of virtualization setup, the complexity it adds to your components, and the edge cases it introduces are not worth it below that threshold. There's no exact number but if you have to ask, you're probably fine without it.
Tables with server-side filtering and search
If users can search or filter the data and your backend handles it, they're rarely looking at more than a page of results at a time. Virtualization solves a problem you've already eliminated at the API level.
Static or infrequently updated content
A documentation site's component reference table, a settings page with a list of options, a reporting table that's exported and not interacted with. These don't need virtualization. They need sensible defaults.
Tables inside modals or fixed-height sidebars with small datasets
A 200-row list inside a 400px sidebar looks like it needs virtualization because the scroll area is constrained. It doesn't. The browser still only paints what's visible. The problem only exists if you have a genuinely large dataset.
The Libraries
Three libraries cover most real-world virtualization needs in React. They differ in API philosophy, flexibility, and how much they ask you to own.
TanStack Virtual (react-virtual v3)
The most flexible option. Headless: it gives you measurements and position calculations, you handle the rendering. No opinion on your markup, your styling, or your scroll container.
npm install @tanstack/react-virtual
Basic list virtualization:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const VirtualList = ({ items }) => {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // estimated row height in px
overscan: 5,
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* This div sets the full scrollable height */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
};
For a table specifically:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const VirtualTable = ({ rows, columns }) => {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, zIndex: 1, background: '#fff' }}>
<tr>
{columns.map((col) => (
<th key={col.key} style={{ padding: '12px 16px', textAlign: 'left' }}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody
style={{
display: 'block',
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
}}
>
{columns.map((col) => (
<td key={col.key} style={{ flex: 1, padding: '12px 16px' }}>
{row[col.key]}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
};
TanStack Virtual is the right choice if you're already using TanStack Table, need full control over markup, or are building something custom.
react-window
Older, smaller, simpler API than TanStack Virtual. Two components: FixedSizeList for equal-height rows, VariableSizeList for dynamic heights. Battle-tested, but development on it has slowed down.
npm install react-window
Fixed height rows:
import { FixedSizeList } from 'react-window';
const Row = ({ index, style, data }) => {
const row = data[index];
return (
<div style={style} className="table-row">
<span>{row.name}</span>
<span>{row.email}</span>
<span>{row.role}</span>
</div>
);
};
const VirtualTable = ({ rows }) => {
return (
<FixedSizeList
height={500}
itemCount={rows.length}
itemSize={52}
width="100%"
itemData={rows}
>
{Row}
</FixedSizeList>
);
};
Variable height rows (you have to measure and cache manually):
import { VariableSizeList } from 'react-window';
import { useRef, useCallback } from 'react';
const VirtualVariableList = ({ items }) => {
const listRef = useRef(null);
const heightCache = useRef({});
const getItemSize = (index) => heightCache.current[index] ?? 52;
const measureRow = useCallback((index, node) => {
if (node && !heightCache.current[index]) {
const height = node.getBoundingClientRect().height;
heightCache.current[index] = height;
listRef.current?.resetAfterIndex(index);
}
}, []);
const Row = ({ index, style }) => (
<div ref={(node) => measureRow(index, node)} style={style}>
{items[index].content}
</div>
);
return (
<VariableSizeList
ref={listRef}
height={500}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
};
Variable height measuring like this is fragile and the source of most react-window bugs. If you need variable heights, react-virtuoso handles it better out of the box.
react-virtuoso
The friendliest API of the three. Handles variable heights automatically, built-in support for sticky headers, grouped lists, infinite scroll, and footer rendering. More opinionated than TanStack Virtual but much less setup.
npm install react-virtuoso
import { TableVirtuoso } from 'react-virtuoso';
const VirtualTable = ({ rows }) => {
return (
<TableVirtuoso
style={{ height: 500 }}
data={rows}
fixedHeaderContent={() => (
<tr style={{ background: '#fff' }}>
<th style={{ width: 200 }}>Name</th>
<th style={{ width: 250 }}>Email</th>
<th style={{ width: 100 }}>Role</th>
</tr>
)}
itemContent={(index, row) => (
<>
<td>{row.name}</td>
<td>{row.email}</td>
<td>{row.role}</td>
</>
)}
/>
);
};
Thats it for a basic virtualized table. Sticky header included. Variable heights handled internally. TableVirtuoso is the table-specific variant; Virtuoso handles lists.
Combining With TanStack Table
In real applications, you usually want sorting, filtering, column resizing, row selection, and pagination logic alongside virtualization. TanStack Table + TanStack Virtual is the standard pairing for this.
npm install @tanstack/react-table @tanstack/react-virtual
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useState, useMemo } from 'react';
const DataTable = ({ data, columns }) => {
const [sorting, setSorting] = useState([]);
const parentRef = useRef(null);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const { rows } = table.getRowModel();
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 10,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, zIndex: 1, background: 'white' }}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
padding: '12px 16px',
textAlign: 'left',
cursor: header.column.getCanSort() ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' ? ' ▲' : ''}
{header.column.getIsSorted() === 'desc' ? ' ▼' : ''}
</th>
))}
</tr>
))}
</thead>
<tbody
style={{
display: 'block',
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
}}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
style={{ flex: 1, padding: '12px 16px', overflow: 'hidden' }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
};
Column definitions:
import { createColumnHelper } from '@tanstack/react-table';
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('name', {
header: 'Name',
cell: (info) => <strong>{info.getValue()}</strong>,
}),
columnHelper.accessor('email', {
header: 'Email',
}),
columnHelper.accessor('role', {
header: 'Role',
cell: (info) => <Badge>{info.getValue()}</Badge>,
}),
columnHelper.accessor('joinedAt', {
header: 'Joined',
cell: (info) => new Date(info.getValue()).toLocaleDateString(),
}),
];
This gives you sorting, custom cell renderers, and virtualization with ~50 lines of component code. Everything scales to 50,000 rows without touching the DOM count.
Infinite Scroll With Virtualization
For data that loads progressively, virtualization and infinite scroll combine well. As the user scrolls toward the bottom, fetch more data and append it to the list. The virtualizer keeps the DOM count low even as the total dataset grows.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useEffect, useCallback } from 'react';
const InfiniteVirtualList = ({ items, hasNextPage, isFetchingNextPage, fetchNextPage }) => {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? items.length + 1 : items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
});
const virtualItems = virtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
useEffect(() => {
if (!lastItem) return;
// When the last virtual item is the sentinel row and we're not already fetching
if (
lastItem.index >= items.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [lastItem?.index, items.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index > items.length - 1;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
<div style={{ padding: '16px', textAlign: 'center' }}>
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
</div>
) : (
<div style={{ padding: '16px' }}>
{items[virtualItem.index].name}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
This works well with TanStack Query's useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
const useUsers = () => {
return useInfiniteQuery({
queryKey: ['users'],
queryFn: ({ pageParam = 0 }) => fetchUsers({ offset: pageParam, limit: 50 }),
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length * 50 : undefined;
},
});
};
const UsersPage = () => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useUsers();
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
return (
<InfiniteVirtualList
items={allItems}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
);
};
Variable Row Heights
Fixed row heights are simple. Variable heights are where virtualization gets complicated.
The problem is that the virtualizer needs to know the position of every row to calculate scroll offsets. With fixed heights, position = index * rowHeight. With variable heights, you have to either know the heights upfront or measure them as rows mount.
Option 1: Estimate and correct
TanStack Virtual lets you provide estimateSize and then measure actual heights after render. It recalculates positions after measurement.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useCallback } from 'react';
const VariableHeightList = ({ items }) => {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // rough estimate
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement} // TanStack measures actual height automatically
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<VariableHeightRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
};
The ref={virtualizer.measureElement} is doing the measurement. TanStack Virtual observes the element size and updates position calculations automatically. This is much cleaner than the manual ResizeObserver approach you'd write with react-window.
Option 2: react-virtuoso
If variable heights are a core requirement, react-virtuoso handles all of this internally. You don't configure anything.
import { Virtuoso } from 'react-virtuoso';
const VariableList = ({ items }) => {
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
itemContent={(index, item) => (
<VariableHeightRow item={item} />
)}
/>
);
};
React-virtuoso measures each row after render and adjusts scroll math on its own. If your rows have genuinely unpredictable heights (expandable sections, dynamic content, multi-line text), this saves a lot of pain.
Alternatives to Virtualization
Pagination
The most boring solution and often the right one. Show 25 or 50 rows at a time, provide page controls. Works without any library. Fully accessible. Scroll position is never a concern.
const PaginatedTable = ({ data, columns }) => {
const [page, setPage] = useState(0);
const pageSize = 50;
const pageData = data.slice(page * pageSize, (page + 1) * pageSize);
const totalPages = Math.ceil(data.length / pageSize);
return (
<div>
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{pageData.map((row) => (
<tr key={row.id}>
{columns.map((col) => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
))}
</tbody>
</table>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}>
Previous
</button>
<span>Page {page + 1} of {totalPages}</span>
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
Next
</button>
</div>
</div>
);
};
If your users are looking for specific rows, pagination also pairs well with search and sort. Virtualization doesn't help with findability, it just makes scrolling less awful.
Server-Side Pagination
The real fix for most large-data problems. Don't send 10,000 rows to the browser. Send 50.
import { useQuery } from '@tanstack/react-query';
const useTableData = ({ page, pageSize, sortBy, filters }) => {
return useQuery({
queryKey: ['table-data', page, pageSize, sortBy, filters],
queryFn: () => fetchTableData({ page, pageSize, sortBy, filters }),
});
};
const ServerTable = ({ columns }) => {
const [page, setPage] = useState(0);
const [sortBy, setSortBy] = useState(null);
const { data, isLoading } = useTableData({ page, pageSize: 50, sortBy });
if (isLoading) return <TableSkeleton />;
return (
<div>
<table>
{/* render data.rows */}
</table>
<Pagination
page={page}
totalPages={data.totalPages}
onPageChange={setPage}
/>
</div>
);
};
If your backend can filter, sort, and paginate, the frontend table is just displaying 50 rows. No virtualization needed. This is almost always the better architecture for data that lives in a database.
Append-Only Infinite Scroll (Without Virtualization)
For feeds where users scroll down and new content appends, you can get away without virtualization if the session length is short and you clean up old rows.
import { useState, useRef, useEffect } from 'react';
const AppendOnlyFeed = () => {
const [items, setItems] = useState(initialItems);
const sentinelRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems().then((newItems) => {
setItems((prev) => {
// Limit total DOM nodes by dropping old items
const combined = [...prev, ...newItems];
return combined.slice(-200); // keep last 200 only
});
});
}
});
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
return (
<div>
{items.map((item) => <FeedItem key={item.id} item={item} />)}
<div ref={sentinelRef} style={{ height: '1px' }} />
</div>
);
};
Dropping old items from the top means the DOM doesn't grow unbounded. Simpler than virtualization, works for low-to-medium volume feeds.
Common Mistakes
Using CSS display: table with absolute positioning
Virtual rows need to be positioned absolutely inside the scroll container. Standard HTML table elements (<table>, <tbody>, <tr>) don't work with position: absolute by default because of how table layout algorithm works.
You have two options:
Option 1: Use display: block on tbody
<tbody style={{ display: 'block', height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<tr
style={{
position: 'absolute',
display: 'flex', // flex children instead of table cells
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<td style={{ flex: 1 }}>...</td>
</tr>
))}
</tbody>
Option 2: Use divs instead of table elements
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
display: 'grid',
gridTemplateColumns: '200px 1fr 100px',
width: '100%',
}}
>
<div>{rows[virtualRow.index].name}</div>
<div>{rows[virtualRow.index].email}</div>
<div>{rows[virtualRow.index].role}</div>
</div>
))}
</div>
Forgetting the sticky header
When using overflow: auto on the scroll container, position: sticky on the thead only works if the sticky positioning is relative to the scroll container, not the viewport. Make sure the scroll container is the direct parent.
// Correct - scroll container wraps the table
<div style={{ height: '600px', overflow: 'auto' }}>
<table>
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
...
</thead>
</table>
</div>
Passing new object references to itemData on every render
In react-window, itemData is passed to every row. If you pass an inline object, every row re-renders on every parent render.
// Every render creates new itemData, every row re-renders
<FixedSizeList itemData={{ rows, onRowClick, selectedIds }}>
{Row}
</FixedSizeList>
// Fixed - stable reference
const itemData = useMemo(
() => ({ rows, onRowClick, selectedIds }),
[rows, onRowClick, selectedIds]
);
<FixedSizeList itemData={itemData}>
{Row}
</FixedSizeList>
Scroll position loss on data refetch
When your data refetches and the array reference changes, the virtualizer recalculates and may jump the scroll position. Preserve scroll position explicitly when updating data in place.
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
// Keep scroll position stable during updates
scrollToFn: useCallback((offset, { behavior }, instance) => {
parentRef.current?.scrollTo({ top: offset, behavior });
}, []),
});
Accessibility
Virtualized lists have real accessibility problems. Screen readers expect all list items to exist in the DOM. When you virtualize, they only see the items currently rendered.
There's no perfect fix, but you can improve things:
<div
ref={parentRef}
role="grid"
aria-rowcount={rows.length} // Tell screen readers the actual count
aria-label="User data table"
style={{ height: '600px', overflow: 'auto' }}
>
For tables where accessibility is a hard requirement (government, enterprise compliance), consider pagination instead. Virtualization and perfect screen reader support don't mix well.
Which Library to Pick
The decision isn't complicated once you know what you need.
TanStack Virtual if you're already in the TanStack ecosystem (Query, Table), want full control over markup, or are building something non-standard. Headless means no CSS conflicts and no opinions to fight against.
react-virtuoso if you need variable row heights, built-in infinite scroll, grouped lists, or a simpler API. Handles the hard parts automatically. Less control, less setup.
react-window if you're maintaining an older codebase that already uses it, or need something very lightweight. Fixed-size rows only, avoid variable height with it.
Need full control over markup?
Yes → TanStack Virtual
Variable row heights?
Yes → react-virtuoso
Already using TanStack Table?
Yes → TanStack Virtual
Need infinite scroll built in?
Yes → react-virtuoso
Everything else → TanStack Virtual (its become the default)
Conclusion
Virtualization solves a real problem. A DOM with 10,000 nodes is slow on any device, and on mobile its often unusable. When you have that problem, virtualization is the right tool and TanStack Virtual is where most new projects should start.
But its a tool with genuine complexity. Scroll position, variable heights, accessibility, CSS layout quirks, sticky headers. None of these are hard problems individually but they add up, and they're problems you don't have with pagination.
If your table has 200 rows, use regular HTML. If your table has 500 rows and the data is paginated server-side, still use regular HTML. If you have thousands of rows that load up front, or a continuous scroll feed that grows without bound, now you need virtualization.
Measure first. Add complexity only when you have evidence that you need it. That's the same advice as every other performance optimization in this series, and it's still true here.





