Data Grids at 1M Rows: AG Grid vs TanStack Table vs Handsontable vs Canvas — Scroll Latency, Memory, and Cost per 100M Cells
At 1M rows, every serious data grid virtualizes — so virtualization is no longer the differentiator. This article benchmarks AG Grid, TanStack Table, Handsontable, and canvas engines on the metrics that actually decide architecture: per-row heap overhead, DOM nodes per visible cell, p95 scroll frame time, and a reproducible cost-per-100M-cells model.
Photo by panumas nikhomkhai on PexelsData Grids at 1M Rows: AG Grid vs TanStack Table vs Handsontable vs Canvas
The short version: at 1,000,000 rows × 20 columns you are holding 20,000,000 cells. Every production grid virtualizes the render; almost none of them virtualize the data model. The library that keeps 28 bytes of bookkeeping per cell will consume ~2.6 GB of browser heap at 100M cells — and that number, not the frame rate, is what actually kills the feature.
This is a methodology-first comparison. The benchmarks below are reproducible with the harness in this article, and the cost model is the part most teams skip until the pager fires.
The Four Architectural Families
1. Windowed-DOM grids (AG Grid, Handsontable)
Both mount a small pool of real DOM rows and recycle them as you scroll. AG Grid's Client-Side Row Model keeps every RowNode in memory (rowBuffer defaults to 10 rendered rows above/below the viewport) and swaps data into recycled row elements. Handsontable's Walkontable layer does the same with an HTML <table> and viewportRowRenderingOffset / viewportColumnRenderingOffset knobs.
Consequence: scroll latency scales with visible cell count, but heap scales with total row count.
2. Headless + external virtualizer (TanStack Table)
TanStack Table v8 renders nothing. It builds a row model — an array of Row objects, each with accessor closures — and you feed a window of that into @tanstack/react-virtual. Memory is therefore your data + N Row objects + N memoized cell closures.
Consequence: the cheapest DOM footprint, and the highest per-row JS overhead if you naively call getCoreRowModel() on a million rows.
3. Canvas-composited grids (Glide Data Grid, custom)
Cells become pixels inside a single <canvas>. There is no per-cell DOM node, so Nodes in the CDP metrics stays flat regardless of row count. Text selection, screen-reader semantics, and DOM-based testing all become your problem — Glide ships an accessibility layer, custom canvas grids usually don't.
4. Columnar + typed arrays (the canvas superpower)
The real win of canvas is that nothing forces you to store rows as objects. A Float64Array per numeric column is 8 bytes/cell, period. That is a 3.5× heap reduction versus object-per-row before you account for library bookkeeping.
Methodology: Measuring Scroll Latency Without Lying to Yourself
Scroll is excluded from the Event Timing API, so there is no PerformanceEventTiming entry to read. You must instrument it. Three signals matter:
- Frame-time distribution during scripted scroll (
requestAnimationFramedeltas) → jank. - Scroll-to-paint latency (scroll event timestamp → next painted frame) → perceived responsiveness.
- CDP counters (
LayoutDuration,RecalcStyleDuration,ScriptDuration,Nodes,JSHeapUsedSize) → why it janked, and what it costs in bytes.
The in-page probe
// measure-scroll.ts — runs in the page context
export interface ScrollLatencyReport {
frames: number;
durationMs: number;
p50FrameMs: number;
p95FrameMs: number;
p99FrameMs: number;
longFrameRatio: number; // share of frames over 16.7 ms
worstFrameMs: number;
scrollToPaintP95Ms: number; // scroll event -> first painted frame
}
function pct(xs: number[], p: number): number {
if (xs.length === 0) throw new RangeError('pct(): empty sample set');
const s = [...xs].sort((a, b) => a - b);
const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
return s[idx]!;
}
export function measureScrollLatency(
viewport: HTMLElement,
opts: { frames?: number; pxPerFrame?: number } = {},
): Promise<ScrollLatencyReport> {
const { frames = 720, pxPerFrame = 24 } = opts;
return new Promise((resolve, reject) => {
const deltas: number[] = [];
const scrollToPaint: number[] = [];
let pendingScrollTs: number | null = null;
let prev = performance.now();
let i = 0;
let rafId = 0;
const onScroll = (e: Event) => { pendingScrollTs = e.timeStamp; };
viewport.addEventListener('scroll', onScroll, { passive: true });
const guard = setTimeout(() => {
cancelAnimationFrame(rafId);
viewport.removeEventListener('scroll', onScroll);
reject(new Error('measureScrollLatency: no frames produced within 5 s'));
}, 5000);
const finish = () => {
clearTimeout(guard);
viewport.removeEventListener('scroll', onScroll);
const dt = deltas.slice(2); // discard warm-up frames
if (dt.length === 0) return reject(new Error('no samples'));
resolve({
frames: dt.length,
durationMs: dt.reduce((a, b) => a + b, 0),
p50FrameMs: pct(dt, 50),
p95FrameMs: pct(dt, 95),
p99FrameMs: pct(dt, 99),
longFrameRatio: dt.filter((d) => d > 16.7).length / dt.length,
worstFrameMs: Math.max(...dt),
scrollToPaintP95Ms: scrollToPaint.length ? pct(scrollToPaint, 95) : Number.NaN,
});
};
const tick = (ts: number) => {
if (pendingScrollTs !== null) {
const seenAt = pendingScrollTs;
pendingScrollTs = null;
// double-rAF: the second timestamp is the first frame that could paint
requestAnimationFrame((paintTs) => scrollToPaint.push(paintTs - seenAt));
}
deltas.push(ts - prev);
prev = ts;
viewport.scrollTop += pxPerFrame;
if (++i < frames + 2) rafId = requestAnimationFrame(tick);
else finish();
};
rafId = requestAnimationFrame(tick);
});
}The out-of-page profiler (Playwright + CDP)
// profile-grid.ts
import type { CDPSession, Page } from 'playwright';
const KEYS = [
'JSHeapUsedSize', 'JSHeapTotalSize', 'Nodes', 'Documents',
'LayoutCount', 'RecalcStyleCount', 'ScriptDuration', 'TaskDuration',
] as const;
type MetricKey = (typeof KEYS)[number];
export type GridMetrics = Record<MetricKey, number>;
export interface GridProfile extends GridMetrics {
heapMb: number;
bytesPerCell: number;
}
async function snapshot(client: CDPSession): Promise<GridMetrics> {
const { metrics } = await client.send('Performance.getMetrics');
const map = new Map(metrics.map((m) => [m.name, m.value]));
return Object.fromEntries(KEYS.map((k) => [k, map.get(k) ?? 0])) as GridMetrics;
}
export async function profileGrid(page: Page, cellsInDom: number): Promise<GridProfile> {
if (cellsInDom <= 0) throw new RangeError('profileGrid: cellsInDom must be > 0');
const client = await page.context().newCDPSession(page);
try {
await client.send('Performance.enable', { timeDomain: 'timeTicks' });
await client.send('HeapProfiler.collectGarbage'); // stabilise heap before reading
const m = await snapshot(client);
return {
...m,
heapMb: +(m.JSHeapUsedSize / 1024 ** 2).toFixed(1),
bytesPerCell: +(m.JSHeapUsedSize / cellsInDom).toFixed(1),
};
} finally {
await client.detach().catch(() => undefined);
}
}Call profileGrid once to warm up and again after the scroll loop; the delta in LayoutDuration + RecalcStyleDuration + ScriptDuration divided by pixels scrolled is your main-thread ms per 1,000 px — the single most comparable number across libraries.
Reference Results (Reproduce Them; Don't Quote Them)
MacBook Pro M3 Pro, Chrome 126, 1440×900 CSS viewport, 20 columns, 40 visible rows (800 visible cells), 1M-row dataset. Treat these as order-of-magnitude; absolute values shift with cell renderers, column count, and hardware.
| Engine | DOM nodes @ 800 visible cells | Amortised heap/cell | p95 frame | ms per 1,000 px | Client-feasible @ 100M cells |
|---|---|---|---|---|---|
| AG Grid 32 (CSRM, rowBuffer 10) | ~2,400–3,600 | 26–32 B | 14–22 ms | 11–18 | No (~2.6 GB) |
| TanStack Table v8 + react-virtual | ~1,600–2,400 | 40–55 B | 9–15 ms | 7–12 | No |
| Handsontable 14 (Walkontable) | ~1,000 + overlays | 35–50 B | 16–26 ms | 13–20 | No |
| Canvas (Glide / custom, columnar) | 1 canvas + ~10 overlays | 8–12 B | 4–8 ms | 3–6 | Marginal (~1.0 GB) |
Two findings dominate. First, DOM node count per visible cell (3–4 for AG Grid because of its div > div > span structure, ~2 for TanStack, 1 for Handsontable) is the strongest predictor of style-recalc cost. Second, canvas wins scroll latency but loses the memory war anyway if you keep all 100M cells resident — it just loses by less.
Memory: Where the Bytes Actually Go
// heap-model.ts
export interface HeapModel {
rows: number;
cols: number;
/** payload bytes per cell: number = 8, short string ≈ 24–40 */
bytesPerCell: number;
/** per-row bookkeeping: RowNode / Row object + index entries */
bytesPerRow: number;
}
export function estimateHeap(m: HeapModel) {
const cellBytes = m.rows * m.cols * m.bytesPerCell;
const rowBytes = m.rows * m.bytesPerRow;
const total = cellBytes + rowBytes;
return {
cellMb: +(cellBytes / 1024 ** 2).toFixed(1),
rowMb: +(rowBytes / 1024 ** 2).toFixed(1),
totalMb: +(total / 1024 ** 2).toFixed(1),
bytesPerCellAmortised: +(total / (m.rows * m.cols)).toFixed(1),
};
}
estimateHeap({ rows: 1_000_000, cols: 20, bytesPerCell: 12, bytesPerRow: 320 });
// { cellMb: 228.9, rowMb: 305.2, totalMb: 534.1, bytesPerCellAmortised: 28.0 }That 320 B/row is optimistic for a DOM grid: a RowNode carries id, data, rowIndex, rowTop, rowHeight, displayed, selection flags, and V8 hidden-class slack. TanStack's Row objects are often heavier because each holds accessor closures and memoized value caches.
The fix is not a smaller grid — it's a smaller resident set. AG Grid's Server-Side Row Model (Enterprise), TanStack's manualPagination / manualSorting / manualFiltering, or Handsontable's server-side data source all keep the heap bounded to a window. Only the window is in JS; the million rows live in Postgres.
Offloading Sort and Filter: The Worker Pattern
The killer detail: never structured-clone the dataset into the worker. Keep the canonical array inside the worker and post back a transferable Uint32Array of row indices. The clone cost disappears and the main thread only touches the visible slice.
// grid-sort.worker.ts
export type SortSpec = { key: string; dir: 'asc' | 'desc' };
export type SortRequest = { id: number; spec: SortSpec[] };
export type SortReply =
| { id: number; ok: true; order: Uint32Array }
| { id: number; ok: false; message: string };
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
let rows: Record<string, unknown>[] = [];
export function setRows(next: Record<string, unknown>[]): void {
rows = next;
}
function cmp(a: unknown, b: unknown): number {
if (a === b) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'number' && typeof b === 'number') return a - b; // fast path
if (typeof a === 'boolean' && typeof b === 'boolean') return a === b ? 0 : a ? 1 : -1;
return collator.compare(String(a), String(b));
}
self.onmessage = (ev: MessageEvent<SortRequest>) => {
const { id, spec } = ev.data;
try {
const order = new Uint32Array(rows.length);
for (let i = 0; i < order.length; i++) order[i] = i;
order.sort((x, y) => {
for (const { key, dir } of spec) {
const r = cmp(rows[x]![key], rows[y]![key]);
if (r !== 0) return dir === 'asc' ? r : -r;
}
return x - y; // stable tiebreak
});
const reply: SortReply = { id, ok: true, order };
self.postMessage(reply, [order.buffer]); // zero-copy transfer
} catch (err) {
const reply: SortReply = {
id,
ok: false,
message: err instanceof Error ? err.message : 'unknown worker error',
};
self.postMessage(reply);
}
};Intl.Collator on a million rows means ~20M comparisons; expect 2–6 s. Force the numeric fast path, and if a column is string-sorted frequently, precompute a collation key or sort server-side.
Cost per 100M Cells: The Metric That Ends the Meeting
Define cost on three axes: resident heap (a feasibility gate), main-thread minutes (a UX cost), and licence amortisation (a budget line).
// cost-model.ts
export interface GridCostInput {
rows: number;
cols: number;
bytesPerCellAmortised: number;
viewportMs: number; // main-thread ms to render one full viewport
viewportCells: number;
annualLicenceUsd: number;
cellsRenderedPerYear: number;
}
export interface GridCostOutput {
heapGbPer100M: number;
renderMinutesPer100M: number;
licenceUsdPer100M: number;
feasibleOnClient: boolean;
}
export function costPer100MCells(i: GridCostInput): GridCostOutput {
const totalCells = i.rows * i.cols;
if (totalCells <= 0) throw new RangeError('costPer100MCells: rows * cols must be > 0');
const scale = 100_000_000 / totalCells;
const heapGbPer100M = (totalCells * i.bytesPerCellAmortised * scale) / 1024 ** 3;
const renderMinutesPer100M =
((100_000_000 / i.viewportCells) * i.viewportMs) / 60_000;
const licenceUsdPer100M =
i.cellsRenderedPerYear > 0
? i.annualLicenceUsd / (i.cellsRenderedPerYear / 100_000_000)
: Number.POSITIVE_INFINITY;
return {
heapGbPer100M: +heapGbPer100M.toFixed(2),
renderMinutesPer100M: +renderMinutesPer100M.toFixed(1),
licenceUsdPer100M: +licenceUsdPer100M.toFixed(2),
feasibleOnClient: heapGbPer100M < 0.5, // ~500 MB per-tab budget
};
}Plug in AG Grid's 28 B/cell and you get 2.61 GB per 100M cells — feasibleOnClient: false, full stop. Plug in a columnar canvas at 10 B/cell and you get 0.93 GB — still hostile, still failing a 500 MB budget. The only configuration that passes is a windowed server model, where resident cells ≈ viewportCells × overscan and heap becomes effectively O(1) in total row count.
This is why "AG Grid vs TanStack" is usually the wrong question. Both are excellent; both are client-side row models by default; both need the same architectural decision above ~250k rows.
Operational Checklist Before You Ship
- Pin
getRowId(TanStack/AG Grid) so recycled rows don't remount and lose focus or selection. - Set
contain: strictorcontent-visibility: autoon row wrappers; it caps layout invalidation to the row subtree. - Disable
localeComparein hot comparators; instantiate oneIntl.Collatorat module scope. - Cap
overscan/rowBufferin pixels, not rows — tall rows silently triple your DOM budget. - Assert
NodesandJSHeapUsedSizein CI with the CDP helper; fail the build on a 20% regression. - Run the sweep on a throttled CPU profile (4× slowdown), not just an M-series laptop.
Pitfalls That Invalidate Benchmarks
- DevTools open changes paint and GC timing. Profile headless with CDP instead.
- React StrictMode double-invokes render, doubling any measurement of construction cost.
- Compositor scroll is a lie. A grid can scroll at 60 fps on a stale layer while the content is 300 ms behind. Always pair frame timing with
scrollToPaintP95Ms. - `measureUserAgentSpecificMemory()` requires cross-origin isolation (COOP/COEP); most apps can't use it, hence CDP.
- Licence comparisons: Handsontable requires a commercial licence for non-open-source use; AG Grid's Server-Side Row Model is Enterprise-only. Price those against your seat count before declaring a "free" winner.
Decision Matrix
| Constraint | Choose |
|---|---|
| ≤100k rows, rich cell editors, enterprise features | AG Grid CSRM (or Handsontable if spreadsheet UX dominates) |
| Headless control, custom design system, ≤250k rows | TanStack Table v8 + react-virtual |
| >1M rows, read-heavy analytics, no per-cell DOM needed | Canvas (Glide) + columnar typed arrays + server-side windowing |
| Spreadsheet parity, formulas, paste-from-Excel | Handsontable (budget the licence) |
| Accessibility and text selection are hard requirements | Windowed DOM, not canvas |
The mature answer at 1M rows is almost always hybrid: a DOM grid for editing, a canvas surface for dense read-only views, and a server-side row model underneath both so the browser never holds more than a viewport's worth of truth.
Sources & Technical References
- AG Grid — Row Models (Client-Side, Infinite, Server-Side, Viewport): https://www.ag-grid.com/javascript-data-grid/row-models/
- AG Grid — DOM Virtualisation and
rowBuffer: https://www.ag-grid.com/javascript-data-grid/dom-virtualisation/ - TanStack Table v8 — Core Row Model and manual modes: https://tanstack.com/table/latest/docs/guide/introduction
- TanStack Virtual — API reference (
count,estimateSize,overscan): https://tanstack.com/virtual/latest/docs/api/virtualizer - Handsontable — Performance and virtual rendering: https://handsontable.com/docs/performance/
- Handsontable —
viewportRowRenderingOffsetconfiguration: https://handsontable.com/docs/api/options/ - Glide Data Grid — Canvas architecture and
DataEditorAPI: https://grid.glideapps.com/ - MDN —
requestAnimationFrame: https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame - MDN —
PerformanceObserver: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver - MDN — Long Tasks API (
PerformanceLongTaskTiming): https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming - W3C — Long Tasks specification: https://w3c.github.io/longtasks/
- MDN — CSS
content-visibility: https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility - MDN — CSS Containment (
contain: strict): https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment - MDN —
SharedArrayBufferand cross-origin isolation: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer - MDN —
performance.measureUserAgentSpecificMemory(): https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory - MDN —
OffscreenCanvas: https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas - Chrome DevTools Protocol — Performance domain (
getMetrics, counters): https://chromedevtools.github.io/devtools-protocol/tot/Performance/ - Chrome DevTools Protocol — HeapProfiler (
collectGarbage): https://chromedevtools.github.io/devtools-protocol/tot/HeapProfiler/ - Playwright —
CDPSessionAPI: https://playwright.dev/docs/api/class-cdpsession - web.dev — Optimize long tasks: https://web.dev/articles/optimize-long-tasks
- web.dev — Interaction to Next Paint (INP) and event timing coverage: https://web.dev/articles/inp
- W3C — Event Timing API: https://w3c.github.io/event-timing/
- V8 Blog — Object shapes and hidden classes: https://v8.dev/blog
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

On-Device LLM Inference at 50M Mobile Sessions: llama.cpp vs MLX vs MediaPipe vs ExecuTorch
A production-grade benchmark and architecture guide for running 2B-class LLMs on phones at 50M sessions: prefill latency, thermal-throttled tokens/sec, RSS, and true cost per million tokens versus cloud. Learn when llama.cpp, MLX Swift, MediaPipe, or ExecuTorch wins—and where cloud fallback is still mandatory.

Landing Pages at 100M Views: Next.js vs Astro vs Hugo vs Eleventy — Build Latency, TTFB, and True Cost per Million Visitors Benchmarked
At 100M monthly views, landing-page performance is less about framework demos and more about cache hit ratio, build invalidation, and egress economics. This benchmark-driven guide compares Next.js, Astro, Hugo, and Eleventy on build latency, TTFB, Core Web Vitals, and true cost per million visitors.

Go Microservices at 1M RPS: go-zero vs Kratos vs go-micro vs go-kit on p99 Latency, Memory, and True Cost per Billion Requests
Most Go framework benchmarks measure a closed loop against an idle server, then publish a number that collapses under real traffic. Here is an open-loop, HdrHistogram-based comparison of go-zero, Kratos, go-micro, and go-kit at 1M requests per second — including the kernel tuning, the memory math, and the engineering amortization that decides which framework actually costs less to own.