A 2026 bake-off of JavaScript table and data-grid libraries. Every library renders the same four datasets — up to 500,000 rows of real property-sale data — through the same harness, with the same theme tokens and the same shared cell formatter. So what you are comparing is the library, not someone's styling effort. The source of every render function is on its own page.
A sibling of line-charts, built the same way. Numbers below are measured, not quoted: bundle sizes and line counts are read from bundles.json, which this build writes.
No library. Hand-built DOM, sticky header, ~20 lines. The control.
One function call. Strong defaults, built for data exploration.
Vanilla, batteries-included: grouping, tree data, editing, export.
The enterprise default. Vanilla core, row + column virtualization.
Headless. It computes rows and sorting; you write every element.
Canvas-rendered, React-only. Spreadsheet feel at any row count.
WASM + Arrow columnar engine in a worker, with a grid attached.
Facts only. Every column here is something this project actually measured — no ratings, no stars, no scores out of five. The judgement calls (default look, API ergonomics, documentation quality) are deliberately not on this page; see the gap note below.
| Library | Bundle (gzip, own code) |
WASM | Lines written |
Render 50,000 |
Render 500,000 |
Scroll FPS at 500,000 |
Survived 500,000? |
Licence |
|---|---|---|---|---|---|---|---|---|
| Plain <table> | — | — | — | 2,625 ms | never finishedcapped at 100,000 → 6.9 s | n/a15 fps at 50,000 | No | n/a |
| Observable Inputs | — | — | — | 5.8 msa | 6.7 msa | 60 fps | Yesa | ISC |
| Tabulator | — | — | — | 68 msb | 280 msb | 60 fps | Yes | MIT |
| AG Grid Community | — | — | — | 35 ms | 248 ms | 60 fps | Yes | MIT (Community) |
| TanStack Table | — | — | — | 20.0 ms | 141 ms | 59 fps | Yes | MIT |
| Glide Data Grid | — | — | — | 17.4 ms | 24.6 ms | 60 fps | Yesc | MIT |
| Perspective | — | — | — | 274 ms | 2,100–2,700 msd | 60 fps | Yes | Apache-2.0 |
hyparquet to read the Parquet files) plus
site.css — and that is not any library's doing. The React demos also have
— of React subtracted, because it is shared between them and is a
cost you may already be paying. That is why the plain <table> row reads
under a kilobyte: its own code really is about twenty lines. The full page weight, with the
harness included, is in bundles.json as totalKB.
src/demos/<key>/. It is a build-cost proxy, not a quality measure, and it
is crude: it counts JSX markup the same as logic, so the React demos are structurally
penalised against a demo that hands a config object to a constructor.
<table>'s capped 100,000-row render varied by over 10% across eight runs on
this machine, and by closer to 20% at 200,000 sampled separately — variance grows with render
cost, not just harness noise. Treat every figure here as an order of magnitude, not a
benchmark. The clock covers building the DOM and forcing layout —
without the layout flush the plain <table> would report a fifth of its
real cost. Bundle and line counts, by contrast, are read from the build at page load.
height: "100%" pattern — the one its docs
give for filling a container — silently zeroes its viewport measurement, so it renders every
row instead of the visible ones: 62 seconds at 50,000 rows, and 500,000
effectively never. Removing that one option gives the 280 ms above. Nothing warned.
role="grid" mirror of the ~15 visible rows, so a screen reader gets the viewport
— but not the other 499,985.
Array<Object>, which is fair but is Perspective's worst
input format; an Arrow buffer or CSV would very likely cut it hard. Once loaded, sorting and
filtering 500,000 rows happen in the worker and the UI thread never notices.
performance.memory to a
placeholder unless it is started with --enable-precise-memory-info, so any memory
figure gathered here would have been invented. The demo pages say heap n/a for the
same reason.
<table> so text is selectable, find-in-page works, dark mode is just CSS. Cost: you write the virtualizer and styling yourself (166 lines vs AG Grid's 62).
<table> cannot render 500,000 rows at all.
Uncapped it was abandoned after ten minutes with the tab unresponsive; the demo caps at
100,000. And the cost is layout, not string building: measured on this machine at
2.6 s at 50,000 rows, 6.9 s at 100,000, roughly 27 s at 200,000 — superlinear, because auto
table layout must measure every cell in a column before it can size that column, and the
larger renders swing 10–20% run to run (see the methodology note below). Building the HTML
string is linear and cheap by comparison.
height: "100%" turns a 40 ms render into a 62 second one at 50,000
rows, with no error, no warning, and nothing on screen to suggest anything is wrong. It is
exactly the kind of thing you copy from a tutorial and never notice on a page of 200 rows.
getCellContent(col, row) callback. Whether that trade is acceptable depends
entirely on whether your users need to search, select or copy what they can see.
<colgroup>, the
sticky header and the sort arrows are all hand-written. What you get back is an ordinary
<table> of your own elements: dark mode is just CSS variables, text is
selectable, and find-in-page works.
Intl option bags with no callback to hand a function
to and no locale field, so its dates render as 7/26/24 where every other demo
shows 2024-07-26. Its columns genuinely do not match the other six. Judge it as a
query interface over data too large for the main thread, which is what it is.
Real HM Land Registry Price Paid data, converted to Parquet and served as static files. Every library gets all four, from the same loader, as the same array of row objects.
Same four datasets, same theme tokens, same shared cell formatter, same mounting harness for all seven. Each demo supplies four render functions and nothing else; the harness owns the page, the card layout, the clock and the FPS run, and empties the host between renders. The render clock awaits async work and forces layout before stopping, so a library that returns early cannot report a scheduling call as a render. The FPS run scrolls the element that actually moves — each demo marks its own internal viewport — because measuring a container that never scrolls returns a flat 60 fps for nothing.
The one exception: Perspective's cells do not pass through the shared formatter,
because it exposes no per-cell hook — only Intl option bags per column. That is a
finding about the library rather than a flaw in the method, but it means its columns are the one
place on this site where a visible difference is not attributable to the library's rendering
alone.
Exploratory spikes that are deliberately not part of the seven-way comparison above — different questions, not an eighth library card.
Per-column summary stats (mini histogram, min/max/mean) above the table, plus in-table filtering, trialed on TanStack.
Run arbitrary SQL against the full 500,000-row dataset with DuckDB-WASM, rendered into TanStack Table.
Contains HM Land Registry data © Crown copyright and database right 2026. This data is licensed
under the Open Government Licence v3.0.
Add a library: copy demos/baseline.html and
src/demos/baseline/main.js, export meta and four render functions,
add the key to DEMOS in vite.config.js, and add a card and a row here.
Bundle size and line count then appear by themselves.