Rendering a Million Rows: The Ultimate Frontend Performance Guide — Virtualization, Streaming, SSR & Beyond
You've got a million rows. Your product manager wants them all accessible in the UI. The designer mocked up a beautiful infinite-scrolling table. And your browser just burst into flames.
Sound familiar?
Rendering massive datasets on the frontend is one of those problems that seems simple until you actually try it. Then you discover that the DOM is not your friend when you're asking it to manage a million nodes.
This guide covers every major technique for handling large datasets on the frontend — from the classics (pagination) to the advanced (canvas rendering, web workers, streaming SSR). We'll look at real code, real tradeoffs, and how companies like Google, Figma, and Bloomberg actually solve this problem.
Let's get into it.
The Problem: Why Naive Rendering Kills the Browser
Let's start with the obvious approach — just render everything:
function NaiveTable({ rows }) {
return (
<table>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
<td>{row.name}</td>
<td>{row.email}</td>
<td>{row.amount}</td>
</tr>
))}
</tbody>
</table>
);
}
With 1,000 rows? Fine. With 10,000? Sluggish. With 100,000+? The tab crashes.
Here's why:
The DOM Tax
Every DOM node costs memory. A single <tr> with three <td> children is ~4 nodes. A million rows = ~4 million DOM nodes. Each node carries weight:
┌─────────────────────────────────────────────┐
│ What Each DOM Node Costs │
├─────────────────────────────────────────────┤
│ Memory allocation ~0.5-1 KB per node │
│ Style calculation O(n) for each node │
│ Layout/reflow Recalculates geometry │
│ Paint Renders pixels │
│ Composite GPU layer management │
└─────────────────────────────────────────────┘
1M rows × 4 nodes × ~0.7 KB ≈ 2.8 GB of DOM memory
The Rendering Pipeline
Every time the browser renders a frame, it goes through this pipeline:
JavaScript → Style → Layout → Paint → Composite
↑ │
└──────── 16.67ms budget (60fps) ──────┘
With a million DOM nodes, the "Layout" step alone can take seconds. The browser has to calculate the position and size of every single element. Scrolling becomes a slideshow.
The Real Bottlenecks
| Bottleneck | Impact | Threshold |
|---|---|---|
| DOM node count | Memory + layout time | >10K nodes gets noticeable |
| Reflow/repaint | Jank during scroll | Any layout change triggers it |
| JavaScript execution | Blocks main thread | >50ms tasks cause jank |
| Memory pressure | Tab crash / OOM | Browser-dependent, ~1-4 GB |
| Garbage collection | Random pauses | More objects = more GC |
The takeaway: you simply cannot put a million DOM nodes on the page. So what do you do?
Technique 1: Pagination — The Classic
The oldest trick in the book: don't show everything at once.
Offset-Based Pagination
// API call
const fetchPage = async (page, pageSize = 50) => {
const res = await fetch(`/api/users?page=${page}&limit=${pageSize}`);
return res.json();
};
function PaginatedTable() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery(
['users', page],
() => fetchPage(page)
);
return (
<>
<table>
<tbody>
{data?.rows.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<span>Page {page} of {data?.totalPages}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page === data?.totalPages}>
Next
</button>
</div>
</>
);
}
Cursor-Based Pagination
Offset pagination breaks down at scale (the database still has to skip N rows). Cursor-based pagination is more efficient:
const fetchPage = async (cursor) => {
const url = cursor
? `/api/users?cursor=${cursor}&limit=50`
: `/api/users?limit=50`;
const res = await fetch(url);
return res.json();
// Returns: { rows: [...], nextCursor: "abc123" }
};
Pagination Pros & Cons
| Aspect | Offset | Cursor |
|---|---|---|
| Jump to page N | Yes | No |
| Consistent with inserts | No (rows shift) | Yes |
| Database performance | O(offset + limit) | O(limit) |
| Implementation | Simple | Moderate |
| Best for | Small datasets, admin panels | Large datasets, feeds |
Pagination is great when users don't need to see everything at once. But sometimes they do — enter virtualization.
Technique 2: Virtualization / Windowing
This is the big one. Virtualization is the single most impactful technique for rendering large lists.
How It Works
The idea is beautifully simple: only render the rows that are visible in the viewport.
┌──────────────────────────────────────┐
│ Virtualized List │
│ │
│ ┌──────────────────────────────┐ │
│ │ Spacer (top padding) │ │ ← Empty div with calculated height
│ │ height: 45000px │ │ (represents rows above viewport)
│ ├──────────────────────────────┤ │
│ │ Row 901 │ │ ┐
│ │ Row 902 │ │ │
│ │ Row 903 │ │ │ Only these ~20 rows
│ │ Row 904 │ │ │ exist in the DOM
│ │ ... │ │ │
│ │ Row 920 │ │ ┘
│ ├──────────────────────────────┤ │
│ │ Spacer (bottom padding) │ │ ← Empty div with calculated height
│ │ height: 54000px │ │ (represents rows below viewport)
│ └──────────────────────────────┘ │
│ │
│ Scrollbar reflects total height │
└──────────────────────────────────────┘
Total rows: 100,000
DOM nodes: ~20 rows × 4 nodes = ~80 nodes
The scroll container has a total height that matches what the full list would be. But only the visible slice (plus a small overscan buffer) is actually rendered. As the user scrolls, rows are recycled — old ones are destroyed, new ones are created.
react-window (Lightweight)
The go-to library for simple virtualized lists:
import { FixedSizeList as List } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style} className="row">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
<span>{items[index].amount}</span>
</div>
);
return (
<List
height={600} // viewport height
itemCount={items.length} // total items
itemSize={35} // row height in px
width="100%"
>
{Row}
</List>
);
}
For variable-height rows, use VariableSizeList:
import { VariableSizeList as List } from 'react-window';
function VariableList({ items }) {
const getItemSize = (index) => {
// Could be dynamic based on content
return items[index].expanded ? 120 : 35;
};
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);
return (
<List
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</List>
);
}
react-virtuoso (Batteries Included)
react-virtuoso handles variable heights automatically — no need to pre-calculate:
import { Virtuoso } from 'react-virtuoso';
function VirtuosoTable({ items }) {
return (
<Virtuoso
style={{ height: '600px' }}
totalCount={items.length}
itemContent={(index) => (
<div className="row">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
)}
// Grouped headers, sticky headers, etc. built in
/>
);
}
It also supports grouped lists, table elements, reverse scrolling (chat UIs), and SSR out of the box.
TanStack Virtual (Framework Agnostic)
TanStack Virtual gives you the primitives — you build the UI:
import { useVirtualizer } from '@tanstack/react-virtual';
function TanStackList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // estimated row height
overscan: 10, // extra rows above/below viewport
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.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)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
Virtualization Library Comparison
| Feature | react-window | react-virtuoso | TanStack Virtual |
|---|---|---|---|
| Bundle size | ~6 KB | ~16 KB | ~10 KB |
| Variable heights | Manual | Automatic | Estimated + measured |
| Table support | Limited | Built-in | Manual |
| Grouped/sticky headers | No | Yes | Manual |
| SSR support | Limited | Yes | Yes |
| Framework agnostic | No (React) | No (React) | Yes |
| Horizontal lists | Yes | Limited | Yes |
| Grid (2D) | Yes | No | Yes |
| Learning curve | Low | Low | Medium |
When Virtualization Isn't Enough
Virtualization handles 100K–1M rows smoothly. But it has limits:
- Scrollbar precision: At 1M rows × 35px = 35M px total height. Browsers cap scrollable height (Chrome: ~33.5M px). You may need to fake the scrollbar.
- Search/filter: You still need all the data in memory for client-side operations.
- Accessibility: Screen readers can't see off-screen rows. You need ARIA attributes to announce total count.
Technique 3: Infinite Scrolling with Intersection Observer
Infinite scroll loads more data as the user approaches the bottom. Combined with virtualization, it's powerful.
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef(null);
// Fetch next page
const loadMore = useCallback(async () => {
const res = await fetch(`/api/items?page=${page}&limit=100`);
const data = await res.json();
setItems(prev => [...prev, ...data.rows]);
setHasMore(data.hasMore);
setPage(prev => prev + 1);
}, [page]);
// Intersection Observer to trigger loading
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
},
{ threshold: 0.1 }
);
if (loaderRef.current) observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [loadMore, hasMore]);
return (
<div style={{ height: '600px', overflow: 'auto' }}>
{items.map(item => (
<div key={item.id} className="row">{item.name}</div>
))}
{hasMore && (
<div ref={loaderRef} className="loader">
Loading more...
</div>
)}
</div>
);
}
Virtualized Infinite Scroll (The Combo)
Pair TanStack Virtual with infinite loading for the best of both worlds:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
function VirtualizedInfiniteList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/items?cursor=${pageParam}&limit=100`).then(r => r.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allRows = data?.pages.flatMap(p => p.rows) ?? [];
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});
// Fetch more when approaching the end
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (!lastItem) return;
if (
lastItem.index >= allRows.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allRows.length, fetchNextPage]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const isLoader = virtualRow.index >= allRows.length;
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: `${virtualRow.start}px`,
width: '100%',
height: `${virtualRow.size}px`,
}}
>
{isLoader ? 'Loading...' : allRows[virtualRow.index].name}
</div>
);
})}
</div>
</div>
);
}
Technique 4: SSR & Streaming with React Server Components
Server-side rendering gets the initial content to the user faster. React 18+ streaming makes it even better.
Streaming HTML with Suspense Boundaries
Instead of waiting for all data to load before sending HTML, you can stream it:
// app/dashboard/page.tsx (Next.js App Router)
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* This renders immediately */}
<header>Welcome back, user</header>
{/* This streams in when the data is ready */}
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
{/* This also renders immediately */}
<footer>Footer content</footer>
</div>
);
}
// Server Component — runs on the server, streams to client
async function DataTable() {
const data = await db.query('SELECT * FROM transactions LIMIT 1000');
return (
<table>
<tbody>
{data.map(row => (
<tr key={row.id}>
<td>{row.date}</td>
<td>{row.description}</td>
<td>${row.amount}</td>
</tr>
))}
</tbody>
</table>
);
}
How Streaming Works Under the Hood
Traditional SSR:
Server: [──── Build full HTML ────] → Send complete HTML → Client renders
Client: [────── waiting ──────────] [── hydrate ──]
Streaming SSR:
Server: [─ Shell ─] → Send shell [─ Data table ─] → Stream chunk
Client: [─ render ─] [─ skeleton ─] [── swap in ──]
Timeline:
0ms 200ms 500ms 1200ms
│ │ │ │
│ Shell │ Skeleton │ Real table │
│ sent │ visible │ streamed in │
The user sees the page layout and skeleton immediately. The heavy data table streams in when it's ready.
Progressive Loading Pattern
For truly massive datasets, combine SSR with client-side virtualization:
// Server Component: sends the first page
async function InitialData() {
const firstPage = await db.query(
'SELECT * FROM items ORDER BY id LIMIT 100'
);
return <VirtualizedTable initialData={firstPage} />;
}
// Client Component: virtualizes and loads more
'use client';
function VirtualizedTable({ initialData }) {
const [items, setItems] = useState(initialData);
// Load more pages on scroll with virtualization
// ... (combine with infinite scroll pattern above)
}
Technique 5: Canvas-Based Rendering
When even virtualized DOM is too slow — think spreadsheet-like UIs with millions of cells — you can skip the DOM entirely and paint pixels on a <canvas>.
Why Canvas?
DOM rendering:
Each cell = DOM node → style calc → layout → paint → composite
10,000 cells = 10,000 nodes through the pipeline
Canvas rendering:
All cells = one <canvas> element → single paint operation
10,000 cells = 10,000 fillRect() calls on one surface
How Spreadsheet Engines Work
┌────────────────────────────────────────────────────┐
│ Canvas Layer │
│ ┌──────┬──────┬──────┬──────┬──────┐ │
│ │ A1 │ B1 │ C1 │ D1 │ E1 │ ← Painted │
│ ├──────┼──────┼──────┼──────┼──────┤ pixels, │
│ │ A2 │ B2 │ C2 │ D2 │ E2 │ not DOM │
│ ├──────┼──────┼──────┼──────┼──────┤ nodes │
│ │ A3 │ B3 │ C3 │ D3 │ E3 │ │
│ └──────┴──────┴──────┴──────┴──────┘ │
├────────────────────────────────────────────────────┤
│ Hidden DOM layer (for accessibility): │
│ <div role="grid" aria-rowcount="1000000"> │
│ <div role="row" aria-rowindex="1"> │
│ <div role="gridcell">A1 content</div> │
│ </div> │
│ </div> │
├────────────────────────────────────────────────────┤
│ Input overlay (for editing): │
│ <input> positioned absolutely over active cell │
└────────────────────────────────────────────────────┘
Basic Canvas Grid Renderer
class CanvasGrid {
constructor(canvas, data, config) {
this.ctx = canvas.getContext('2d');
this.data = data; // 2D array of cell values
this.cellWidth = config.cellWidth || 120;
this.cellHeight = config.cellHeight || 30;
this.scrollX = 0;
this.scrollY = 0;
this.visibleRows = Math.ceil(canvas.height / this.cellHeight) + 1;
this.visibleCols = Math.ceil(canvas.width / this.cellWidth) + 1;
canvas.addEventListener('wheel', (e) => this.onScroll(e));
this.render();
}
onScroll(e) {
this.scrollX += e.deltaX;
this.scrollY += e.deltaY;
this.scrollX = Math.max(0, this.scrollX);
this.scrollY = Math.max(0, this.scrollY);
requestAnimationFrame(() => this.render());
}
render() {
const { ctx, data, cellWidth, cellHeight } = this;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const startRow = Math.floor(this.scrollY / cellHeight);
const startCol = Math.floor(this.scrollX / cellWidth);
const offsetY = -(this.scrollY % cellHeight);
const offsetX = -(this.scrollX % cellWidth);
for (let r = 0; r < this.visibleRows; r++) {
for (let c = 0; c < this.visibleCols; c++) {
const dataRow = startRow + r;
const dataCol = startCol + c;
if (dataRow >= data.length || dataCol >= data[0].length) continue;
const x = offsetX + c * cellWidth;
const y = offsetY + r * cellHeight;
// Draw cell border
ctx.strokeStyle = '#e0e0e0';
ctx.strokeRect(x, y, cellWidth, cellHeight);
// Draw cell text
ctx.fillStyle = '#333';
ctx.font = '13px Inter, sans-serif';
ctx.fillText(
data[dataRow][dataCol],
x + 8,
y + cellHeight / 2 + 4,
);
}
}
}
}
Libraries That Use Canvas Rendering
| Library | Approach | Best For |
|---|---|---|
| ag-Grid (Enterprise) | Canvas for cells, DOM for chrome | Data grids with millions of rows |
| Handsontable | Canvas hybrid | Spreadsheet UIs |
| Glide Data Grid | Full canvas | React-based spreadsheets |
| PixiJS + custom | WebGL/Canvas | Custom visualizations |
| Luckysheet | Canvas | Google Sheets clone |
Technique 6: Web Workers for Off-Main-Thread Computation
Sorting, filtering, or aggregating a million rows on the main thread blocks the UI. Move it to a Web Worker.
// worker.js
self.onmessage = function(e) {
const { type, data, params } = e.data;
switch (type) {
case 'sort': {
const sorted = [...data].sort((a, b) => {
const valA = a[params.column];
const valB = b[params.column];
return params.direction === 'asc'
? valA > valB ? 1 : -1
: valA < valB ? 1 : -1;
});
self.postMessage({ type: 'sorted', data: sorted });
break;
}
case 'filter': {
const filtered = data.filter(row =>
row[params.column]
.toLowerCase()
.includes(params.query.toLowerCase())
);
self.postMessage({ type: 'filtered', data: filtered });
break;
}
case 'aggregate': {
const sum = data.reduce((acc, row) => acc + row[params.column], 0);
const avg = sum / data.length;
self.postMessage({
type: 'aggregated',
data: { sum, avg, count: data.length },
});
break;
}
}
};
// useWorkerSort.js — Custom hook
function useWorkerSort(data) {
const [sorted, setSorted] = useState(data);
const [sorting, setSorting] = useState(false);
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
);
workerRef.current.onmessage = (e) => {
if (e.data.type === 'sorted') {
setSorted(e.data.data);
setSorting(false);
}
};
return () => workerRef.current.terminate();
}, []);
const sort = useCallback((column, direction) => {
setSorting(true);
workerRef.current.postMessage({
type: 'sort',
data,
params: { column, direction },
});
}, [data]);
return { sorted, sorting, sort };
}
Transferable Objects for Zero-Copy
For really large datasets, use Transferable objects to avoid copying data between threads:
// Instead of copying the array:
worker.postMessage({ data: hugeArray });
// Transfer ownership (zero-copy, but main thread loses access):
const buffer = new Float64Array(1_000_000);
worker.postMessage({ buffer }, [buffer.buffer]);
// buffer is now empty on the main thread — ownership transferred
Technique 7: requestAnimationFrame & Time-Slicing
If you must render many DOM nodes (maybe you're building a static report), do it in chunks so the browser stays responsive:
function renderInChunks(items, container, chunkSize = 500) {
let index = 0;
function renderChunk() {
const fragment = document.createDocumentFragment();
const end = Math.min(index + chunkSize, items.length);
for (let i = index; i < end; i++) {
const row = document.createElement('div');
row.className = 'row';
row.textContent = items[i].name;
fragment.appendChild(row);
}
container.appendChild(fragment);
index = end;
if (index < items.length) {
// Yield to the browser — let it paint, handle events, etc.
requestAnimationFrame(renderChunk);
}
}
requestAnimationFrame(renderChunk);
}
React 18's Built-in Time-Slicing with useTransition
React 18 gives you this for free:
function SearchableList({ items }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(items);
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
const value = e.target.value;
setQuery(value); // Urgent update — input stays responsive
startTransition(() => {
// Low-priority — React can interrupt this to keep UI responsive
const result = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFiltered(result);
});
};
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending && <span>Filtering...</span>}
<VirtualizedList items={filtered} />
</div>
);
}
React will break up the state update into smaller chunks and yield back to the browser between them. The input never feels sluggish.
Technique 8: CSS Containment & content-visibility
These CSS properties tell the browser it can skip work for off-screen elements.
content-visibility: auto
This is the CSS equivalent of virtualization — the browser skips rendering for off-screen elements:
.row {
content-visibility: auto;
contain-intrinsic-size: 0 50px; /* estimated height when hidden */
}
Without content-visibility:
Browser renders ALL 100,000 rows
┌─────────────────────┐
│ Row 1 [rendered] │ viewport
│ Row 2 [rendered] │
│ Row 3 [rendered] │
├─────────────────────┤
│ Row 4 [rendered] │ off-screen but still
│ Row 5 [rendered] │ fully rendered
│ ... │
│ Row 100K [rendered] │
└─────────────────────┘
With content-visibility: auto:
Browser only renders visible rows
┌─────────────────────┐
│ Row 1 [rendered] │ viewport
│ Row 2 [rendered] │
│ Row 3 [rendered] │
├─────────────────────┤
│ Row 4 [skipped] │ layout calculated,
│ Row 5 [skipped] │ but paint/style skipped
│ ... │
│ Row 100K [skipped] │
└─────────────────────┘
CSS contain Property
More granular control over what the browser can optimize:
.data-table-row {
contain: layout style paint;
/* layout: this element's layout doesn't affect siblings */
/* style: counters/etc don't leak out */
/* paint: children don't paint outside bounds */
}
/* Nuclear option — all containment */
.isolated-widget {
contain: strict;
}
When to Use CSS vs. JavaScript Virtualization
| Approach | DOM Nodes | Memory | Scroll Perf | Setup |
|---|---|---|---|---|
| content-visibility | All in DOM | High | Good | 2 lines of CSS |
| JS Virtualization | Only visible | Low | Excellent | Library setup |
Use content-visibility for moderate lists (1K–10K items). Use JS virtualization for anything larger.
Technique 9: Lazy Loading Strategies
Progressive Disclosure
Show summary first, load details on demand:
function ExpandableRow({ row }) {
const [expanded, setExpanded] = useState(false);
const [details, setDetails] = useState(null);
const handleExpand = async () => {
if (!expanded && !details) {
const res = await fetch(`/api/rows/${row.id}/details`);
setDetails(await res.json());
}
setExpanded(!expanded);
};
return (
<>
<tr onClick={handleExpand}>
<td>{row.name}</td>
<td>{row.summary}</td>
<td>{expanded ? '▼' : '▶'}</td>
</tr>
{expanded && details && (
<tr>
<td colSpan={3}>
<DetailView data={details} />
</td>
</tr>
)}
</>
);
}
Skeleton Screens
Show structure immediately, fill in data when ready:
function TableSkeleton({ rows = 20, cols = 5 }) {
return (
<table>
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{Array.from({ length: cols }).map((_, j) => (
<td key={j}>
<div
className="skeleton-pulse"
style={{
width: `${60 + Math.random() * 40}%`,
height: '16px',
borderRadius: '4px',
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Table Libraries Compared
If you don't want to build from scratch, here's how the major table libraries stack up:
| Feature | TanStack Table | ag-Grid | MUI DataGrid |
|---|---|---|---|
| Bundle size | ~15 KB | ~200 KB (community) | ~90 KB |
| Virtualization | Via TanStack Virtual | Built-in (excellent) | Built-in |
| Max rows tested | 100K+ | 1M+ (canvas mode) | 100K+ |
| Sorting | Built-in | Built-in + server | Built-in + server |
| Filtering | Built-in | Built-in (advanced) | Built-in |
| Grouping/Pivot | Plugin | Enterprise feature | Pro feature |
| Column resize | Plugin | Built-in | Built-in |
| Editing | Manual | Built-in | Built-in |
| Export (CSV/Excel) | Manual | Enterprise | Pro |
| Rendering | DOM | DOM + Canvas hybrid | DOM |
| Framework | Any | Any (React/Vue/Angular) | React (MUI) |
| License | MIT | Community: MIT / Enterprise: paid | Community: MIT / Pro: paid |
| Headless? | Yes (UI agnostic) | No (provides UI) | No (provides UI) |
Quick Recommendation
- Headless + full control: TanStack Table + TanStack Virtual
- Enterprise data grid: ag-Grid Enterprise (nothing else comes close at 1M+ rows)
- MUI ecosystem: MUI DataGrid Pro
- Simple table with sort/filter: TanStack Table (lightweight, flexible)
Real-World Examples
How Google Sheets Handles Millions of Cells
Google Sheets renders a canvas-based grid for the cell area. The DOM is used only for the toolbar, menus, and accessibility layer. Key techniques:
-
Canvas rendering for cells — one
<canvas>element for the entire sheet - Tile-based rendering — the sheet is divided into tiles, only visible tiles are rendered
- Web Workers for formula calculation — complex formulas don't block scrolling
- Incremental loading — only the visible range is fetched from the server
How Figma Renders Complex UIs
Figma uses a WebGL canvas for the design surface. The entire editor is rendered via GPU:
- WebGL rendering — GPU-accelerated, not DOM-based
- Custom text layout engine — doesn't use the browser's text rendering for the canvas
- WASM for performance-critical code paths
- Off-screen rendering — invisible objects are culled from the render tree
How Bloomberg Terminal Handles Real-Time Data
Bloomberg Terminal (web version) processes thousands of real-time updates per second:
- WebSocket streams for live price feeds
- Double-buffering — updates are batched and swapped in on the next frame
- DOM recycling — rows are reused, only cell content changes
- requestAnimationFrame batching — coalesces rapid updates into single frames
Performance Metrics That Matter
When optimizing large list rendering, track these metrics:
| Metric | What It Measures | Target | Tool |
|---|---|---|---|
| FCP (First Contentful Paint) | When first content appears | < 1.8s | Lighthouse |
| LCP (Largest Contentful Paint) | When main content is visible | < 2.5s | Lighthouse |
| TTI (Time to Interactive) | When page is fully interactive | < 3.8s | Lighthouse |
| TBT (Total Blocking Time) | Main thread blocked time | < 200ms | Lighthouse |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 | Lighthouse |
| INP (Interaction to Next Paint) | Input responsiveness | < 200ms | Chrome UX Report |
| FPS during scroll | Scroll smoothness | 60 fps | Chrome DevTools |
| JS Heap Size | Memory consumption | < 200 MB | DevTools Memory tab |
| DOM Node Count | DOM complexity | < 1,500 | Lighthouse |
Profiling Scroll Performance
// Quick FPS counter for development
let lastTime = performance.now();
let frames = 0;
function measureFPS() {
frames++;
const now = performance.now();
if (now - lastTime >= 1000) {
console.log(`FPS: ${frames}`);
frames = 0;
lastTime = now;
}
requestAnimationFrame(measureFPS);
}
requestAnimationFrame(measureFPS);
Decision Framework: Which Technique for Which Scale
How many rows do you need to display?
│
├─ < 100 rows
│ └─ Just render them. No optimization needed.
│
├─ 100 – 1,000 rows
│ └─ Pagination OR content-visibility: auto
│ Simple, effective, almost zero overhead.
│
├─ 1,000 – 10,000 rows
│ └─ Virtualization (react-window / react-virtuoso)
│ + Pagination or infinite scroll for data fetching
│
├─ 10,000 – 100,000 rows
│ └─ Virtualization + Web Workers for sort/filter
│ + Server-side pagination (cursor-based)
│ + Streaming SSR for initial load
│
├─ 100,000 – 1,000,000 rows
│ └─ Virtualization + Web Workers + Canvas hybrid
│ + Server-streaming (NDJSON / gRPC)
│ + Consider ag-Grid Enterprise
│
└─ > 1,000,000 rows
└─ Full canvas rendering (like Google Sheets)
+ Server-side everything (sort, filter, aggregate)
+ WebSocket for real-time updates
+ Possibly WASM for client-side computation
Quick Reference Table
| Scale | Technique | Complexity | Time to Implement |
|---|---|---|---|
| < 1K | Raw rendering + CSS | Low | Minutes |
| 1K–10K | Virtualization library | Low-Med | Hours |
| 10K–100K | Virtual + workers + streaming | Medium | Days |
| 100K–1M | Canvas hybrid + server-side ops | High | Weeks |
| > 1M | Full canvas + WASM + custom infra | Very High | Months |
Wrapping Up
Rendering massive datasets is fundamentally about one thing: doing less work. Every technique we covered is a way to avoid unnecessary computation:
- Pagination — don't load what the user hasn't asked for
- Virtualization — don't render what the user can't see
- Canvas — don't create DOM nodes for things that are just pixels
- Web Workers — don't block the UI thread with computation
- Streaming — don't wait for everything before showing something
- CSS containment — don't paint what's off-screen
Start with the simplest technique that meets your requirements. Pagination solves 80% of use cases. Virtualization covers another 15%. Canvas rendering and WASM are for the remaining 5% — the Google Sheets and Figma-scale problems.
The best optimization is the one you don't need. But when you do need it, now you know every option on the table.
Let's Connect!
If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.
Connect with me on LinkedIn — let's grow together.
Drop a comment, share this with someone who needs this, and follow along for more guides like this!
Top comments (0)