Decision. Virtualize above ~1k rows and the row count stops mattering: every serious grid renders ~40 rows regardless of dataset size [10]. What actually breaks is (a) update frequency — MUI X users hit a wall at ~10 updates/sec [13] while AG Grid batches ticks outside React [12]; (b) referential stability — an inline
.filter()ondataputs TanStack Table into an infinite render loop [22]; and (c) a hard browser cap of 17.5–33.5M px on scroll-container height [6], which makes ~500k–900k rows the ceiling for any natively-scrolling grid, canvas included. Past that you need a server-side row model [11] or a custom scrollbar [9].
Every 2026 grid benchmark is written by someone selling a grid
Read this before you read any number below.
| Benchmark | Author | Also ships | Winner (surprise) |
|---|---|---|---|
| 1771 Technologies [3] | 1771 Technologies | LyteNyte Grid | LyteNyte |
| Battle of the Rows [5] | Revolist OU | RevoGrid | RevoGrid |
| react-data-grid-benchmark [1] | Vitashev | Ace Grid | React Data Grid ⚠ not his own |
They also contradict each other. RevoGrid measures AG Grid at 1,297 MB and a crashed tab at 400k rows [5]; 1771 measures AG Grid scrolling 1M rows at 27 FPS without crashing [3]. Both cannot be true. Trust the one that ships a reproducible fixture, a package lock, and raw samples — currently only Vitashev’s, which publishes its conflict of interest in the README and still doesn’t put its own grid first [2] ⭐ 0.
The one reproducible table
50,000 rows × 20 fixed-width columns, 1200×600 viewport, 36px rows, sorting + filtering + two editable columns, production bundles, pinned versions [1]:
| Grid | JS gzip | Ready (median) | Scroll settle | Mounted cells |
|---|---|---|---|---|
| React Data Grid ⭐ 7.7k | 72.3 KB | 367.6 ms | 21.2 ms | 264 |
| TanStack Table + Virtual ⭐ 28k | 78.7 KB | 369.3 ms | 21.7 ms | 444 |
| Ace Grid Core | 224.5 KB | 387.4 ms | 28.1 ms | 220 |
| AG Grid Community ⭐ 15k | 358.7 KB | 443.2 ms | 29.8 ms | 360 |
| Handsontable ⭐ 22k | 380.9 KB | 872.8 ms | 49.8 ms | 198 |
MUI X Community was excluded from the ranking — its Community tier can’t virtualize past 100 rows without pagination [6].
Read: at 50k rows the renderer is not the bottleneck for anyone. The 76 ms spread between fastest and slowest (ex-Handsontable) is bundle parse + init, not scrolling. Scroll settle is 21–30 ms across the board. Nobody is slow at 50k rows. Pick on features, not FPS, below ~100k.
The ceiling nobody markets: the scroll-container pixel cap
This is the single most important number in the field and no vendor page leads with it.
Browsers truncate an element’s height. MUI documents 17.5M px on Firefox and 33.5M px on Chrome/Edge/Safari [6]; the open TanStack Virtual issue cites 33,554,400 (FF) / 33,554,428 (Chrome) [7] ⭐ 7.0k; a third source measures Chromium at 16,777,216 [8]. The exact figure is engine- and version-dependent — budget for the low end, ~16.7M px.
Divide by row height:
| Row height | Rows at 16.7M px | Rows at 33.5M px |
|---|---|---|
| 22 px | ~760k | ~1.5M |
| 32 px | ~520k | ~1.0M |
| 36 px | ~465k | ~930k |
| 56 px (Material) | ~300k | ~600k |
Beyond that the scroll container is silently clipped and the tail of your data is unreachable — the virtualizer does not error, it just stops. TanStack Virtual’s issue is still open [7]: a headless virtualizer ships indices and offsets, not a scrollbar, so this is explicitly your problem [20].
Three escapes:
- Server-side row model. AG Grid’s SSRM demo serves 10M records and moves the constraint to your backend [11]. This is the boring correct answer.
- Custom scrollbar. Replace native scrolling; position rows absolutely against a synthetic scroll position [8].
- Downscaled scrollbar resolution. HighTable caps the canvas at ~8M px and switches to a
globalAnchor+localOffsetmodel above it, claiming 1-px fidelity to ~2 trillion rows at 30px [9].
⚠ Canvas does not exempt you. Glide Data Grid uses native scrolling [16] — same pixel cap. Canvas fixes paint cost, not scroll geometry.
Virtualizer selection (when you’re building, not buying)
Only relevant if you pair a headless table (TanStack Table, or hand-rolled) with a virtualizer. Bought grids have virtualization built in and you should not layer another on top.
| Library | Bundle | Latest | Dynamic heights | Verdict |
|---|---|---|---|---|
| @tanstack/react-virtual ⭐ 7.0k | ~5 KB [19] | 3.14.6 (Jul 2026) | measureElement ref |
Default if you’re already on TanStack Table; headless, no markup [20] |
| react-virtuoso ⭐ 6.4k | ~17 KB [19] | 4.18.10 (Jun 2026) | auto, ResizeObserver | Fewest footguns; sticky groups, bidirectional infinite scroll built in |
| react-window ⭐ 17k | ~6 KB | 2.2.7 (Feb 2026) | useDynamicRowHeight (new in v2) |
⚠ Stop repeating the “abandoned, fixed-height-only” line — v2 is a full rewrite [21] |
All three hold 60 FPS at 100k items on modern hardware; initial render differs by single-digit milliseconds [19]. This choice is not a performance decision. It’s an API-surface decision.
Virtualization becomes worth its overhead somewhere around 50+ simultaneously-rendered rows; below that you’re paying measurement cost for nothing [24].
DOM vs canvas
| DOM (+ virtualization) | Canvas (Glide ⭐ 5.3k) | |
|---|---|---|
| Paint cost at scroll | Create/destroy N nodes per frame — the argument Glide makes against DOM [15] | Repaint dirty rects; flat memory 100 rows → 10M rows [16] |
| High-frequency updates | Reconciler-bound | Vendor claims 100k+ updates/sec [16] — unverified by any third party |
| Text selection / Ctrl-F / translate / autofill | ✓ free | ✗ must be re-implemented [18] |
| Accessibility | ✓ real semantics (though pinned columns + virtualization mangle DOM order anyway) | ⚠ hidden mirror DOM; maintainers admit “none of the primary developers are accessibility users so there are likely flaws” [15] |
| Custom cell rendering | Any React component | Constrained to their cell-renderer model |
| CSS / theming | ✓ | ✗ draw calls |
The dealbreaker isn’t architectural, it’s maintenance. @glideapps/glide-data-grid latest stable is 6.0.3, published 2024-02-03 — no stable release in 29 months [17]. Adopting it in 2026 means owning a canvas renderer.
HN practitioner view is also less one-sided than Glide’s README: canvas wins scroll and streaming, loses layout primitives, and a well-built DOM grid can close much of the gap [18].
The React tax: architecture determines re-render cost
This is where the libraries genuinely diverge, and it has nothing to do with row count.
Framework-agnostic core, React only at the edges — AG Grid, Handsontable. Cell updates never enter the reconciler. AG Grid’s applyTransactionAsync() coalesces streaming ticks into a single flush every 50ms by default [12]. This is why AG Grid is the trading-blotter default and why the vendor benchmark still puts it at 39 FPS on cell updates against MUI’s 22 [4].
Pure React component tree — MUI X Data Grid. Every cell is a React element; the grid memoizes cells but the caller must keep every non-primitive prop referentially stable or React.memo is defeated at the root [14]. Users report the grid becoming “slow and nearly unusable” at ~10 row-updates/sec on a 400-row window, where their previous plain HTML table handled 100/sec [13] ⭐ 5.8k. ⚠ MUI X is a display grid at scale, not a streaming grid.
Headless — you own all of it — TanStack Table. Zero rendering, so zero built-in re-render cost, and zero built-in protection. The classic failure is referential, not algorithmic: unstable columns/data → infinite render loop, and memoizing the base array doesn’t save you if you then .filter() inline in the props [22] ⭐ 28k. Note @tanstack/react-table is still on 8.21.3 (Apr 2025) [26] — stable, not dead, but v9 has not landed.
Memoization is not free
The instinct to memo() your way out is a trap. Material React Table’s own guide is blunt: memoMode: 'table-body' disables virtualization and most features, leaving a table “mostly frozen after first render”; memoizing rows loses access to table state like getIsSelected() [23]. Memoize the data, not the render tree.
React Compiler auto-memoizes at build time and removes the need for manual useMemo/useCallback/React.memo [27] — but it cannot fix an unstable data reference produced by an inline .filter(), because that’s a new array by construction, not a missed memo. The compiler does not save you from the one bug that actually bites here [22].
Column virtualization and wide tables
Row virtualization is table stakes; column virtualization is where wide grids quietly die. 100k rows × 100 columns is 10M cells; without dual-axis virtualization the mounted-cell count scales with column count even when rows are virtualized.
- Threshold: don’t bother under ~12–20 columns; it pays from “dozens” upward, and the MRT reference example uses 500 [24].
- MUI X overscans 150px of columns by default (
columnBufferPx) and — the trap — disables column virtualization entirely when dynamic row height is enabled [6]. Wide + auto-height = every column mounted. - React Data Grid and AG Grid virtualize both axes by default; the reproducible fixture shows RDG holding 264 mounted cells against TanStack+Virtual’s 444 at 20 columns [1].
Practical ceilings
| Library | Comfortable | Degrades | Hard stop |
|---|---|---|---|
| AG Grid (client-side) | 100k+ [10] | ~1M: 27 FPS scroll (vendor-measured) [3] | browser memory / scroll cap |
| AG Grid (SSRM) | 10M+ [11] | — | your backend |
| TanStack Table + Virtual | 100k+ [1] | when you mis-memoize | scroll cap — unsolved upstream [7] |
| React Data Grid | 50k measured, leanest bundle [1] | — | still 7.0.0-beta after years |
| MUI X Data Grid | 10k–100k display-only | ~10 updates/sec [13]; 1M scroll ≈ 33 FPS (vendor) [3] | Community caps virtualization at 100 rows [6]; ⚠ open 199 MB/refresh leak at 100k [28] |
| Handsontable | ≤ ~150k; docs steer you to pagination [25] | 872 ms ready at 50k — 2.4× the field [1] | OOM crash reported at 200k–500k [4] and 400k [5] |
| Glide Data Grid | millions (vendor) [16] | — | ⚠ unmaintained since Feb 2024 [17] |
What to actually benchmark
Vendor FPS numbers on an M4 MacBook Air [3] tell you nothing about your users’ 2019 ThinkPads. Measure these four, on your data:
- Mounted cell count after a scroll to the middle — the honest proxy for virtualization quality [1].
- Updates/sec before frame drops — the metric that separates AG Grid from MUI X [12] [13].
rowCount × rowHeightvs 16.7M px — a pure-arithmetic go/no-go on native scrolling [8].- Heap after 5 minutes of data refresh — an open MUI X issue leaks ~199 MB per refresh at 100k rows, reaching 4.7 GB, with retained
CellWidgetinstances fromrenderCell[28] ⭐ 5.8k.