Performance
The number worth reading is the DOM node count: it is the same at a million rows as at a hundred thousand, because only the visible window is rendered. The heap is your data, not the grid's overhead.
Measure It Here
This runs in your browser, on this page, right now. Each button replaces the grid's data, forces the pipeline to run, sorts by salary, and counts the cells that ended up in the DOM. The row count grows by a hundredfold; the cell count does not move.
// What the demo below measures, which is what any grid can measure of
// itself: the pipeline is plain state, so timing it needs no hooks.
function measure(rows: number) {
const data = makeRows(rows);
const t0 = performance.now();
grid.data = data;
const read = grid.totalRows; // forces the pipeline to run
const load = performance.now() - t0;
const t1 = performance.now();
getSorting(grid)?.setSort([{ columnId: 'salary', direction: 'desc' }]);
grid.preWindowNodes.length; // forces the sort stage
const sort = performance.now() - t1;
const nodes = document.querySelectorAll('[data-dg-cell]').length;
return { load, sort, nodes };
}The Published Numbers
Measured by the library on Chromium at a 1500x950 viewport, with 39 columns of mixed renderers: currency, percent, date, badge, progress, rating and boolean. They come from its own stress route, so they can be re-run rather than taken on trust.
| Metric | 100k rows | 500k rows | 1M rows |
|---|---|---|---|
| Data into the grid | 219ms | 251ms | 416ms |
| JS heap | 100MB | 315MB | 472MB |
| DOM nodes | 779 | 779 | 779 |
| Scroll, median frame | 19ms | 23ms | 35ms |
Sorting and filtering are measured apart from those, by the library's own bench rather than in a browser: they are arithmetic rather than rendering, and a browser adds noise to them. 100k rows, four columns, best of ten.
| Operation | Time |
|---|---|
| Sort by number | 18ms |
| Sort by string | 265ms |
| Multi-sort, string and number | 264ms |
| Quick filter, per keystroke | 6ms |
| Build row nodes | 2ms |
A string sort is slower than a numeric one by an order of magnitude and stays that way: most
of it is Intl.Collator, which is what puts Item 2 before Item 10,
and the grid does not trade that away for speed. The quick filter figure is a later keystroke,
not the first: the first builds the text every one after it reuses.
Known Limits
Measured rather than assumed, and stated so nobody has to discover them in production.
| Limit | What it means |
|---|---|
Scrolling holds 60fps to about half a million rows | It falls to roughly 28fps at a million with 39 columns. Fewer columns move that line out |
The quick filter pays for its first keystroke and reuses it after | The first pass is O(rows x visible columns) and formats every cell, since the filter matches what a cell draws. Measured at a million rows across four columns: 2.5s for the first keystroke, 180ms for each one after, at roughly 7MB of held text per 100k rows. Filter fewer columns, or move to a server row model, where that first pass is what matters |
A very wide grid costs its column list, not its columns | Only the columns in view are rendered, bounded even before the container is measured, so the cells drawn stay flat as columns are added. What grows is the arithmetic behind them and the CSS grid template every row declares: the library measures 20,000 columns mounting in about 300ms at 100 rows, of which the template is 117KB per row, and the real world example swaps a 20,000-column list in 195ms with the same 161 cells in the DOM either way |
Past the browser maximum element height the scroll range is scaled | Every row stays reachable, but a pixel of scrolling covers more than a pixel of content. Engines differ on where that starts, so the grid caps below the lowest in wide use |
getRowHeight: 'auto' costs a Fenwick tree | O(log n) per offset lookup rather than arithmetic, plus a measurement pass per row. Prefer a fixed height where the rows allow it |
rowSpan resolves against the whole row list | One pass per spanning column on every sort or filter. Fine for report-shaped data, costly at a million rows |
Row reorder rewrites data | An active sort re-sorts it immediately, so the move is real in the data and invisible on screen |
Row Heights
A fixed height keeps offsets as arithmetic. Reach for measured rows only where the content genuinely differs, and never together with rowSpan, which is sized from the rows it covers.
Both grids below hold the same 5,000 rows. The first is rowHeight: 40, the second is getRowHeight: () => 'auto', which measures every row it renders and keeps the offsets in a Fenwick tree. Jump both to
row 4,800 and compare, remembering that one number on one machine is an anecdote rather than
a benchmark.
// A fixed row height is the fast path: an offset is arithmetic.
virtualization({ rowHeight: 40 });
// getRowHeight, including 'auto', switches the virtualizer to a Fenwick
// tree: O(log n) per offset lookup rather than a multiplication, plus a
// measurement pass for each 'auto' row.
virtualization({ rowHeight: 40, getRowHeight: (node) => node.row.tall ? 'auto' : 40 });The Quick Filter
It is the one operation that scales with columns as well as rows, because it has to stringify each visible cell to search it. Both grids below hold the same 20,000 rows and differ only in how many columns are visible, so the gap between the two numbers is the cost of the four extra columns.
// The first keystroke is O(rows x visible columns) on the main thread: it
// reads every visible cell of every row and formats it, since the filter
// matches what a cell draws as well as the value behind it. At a million
// rows across four columns that is about 2.5 seconds of blocked UI.
// Every keystroke after it is one substring test per row against text
// already built, at about 180ms for the same million rows. The text is
// held against the row object, so it is dropped when the row is edited,
// when data is replaced, and when the visible columns or the language
// change - roughly 7MB per 100k rows while it is held.
// Narrow what it searches by hiding columns the user does not need,
{ id: 'notes', hidden: true }
// or skip it entirely and give those columns their own filters,
{ id: 'name', filter: 'text' }
// or move the work off the client.
createDataGrid<Person>({ columns, data, getRowId, rowModel: 'server' });What to Keep Cheap
Three hooks run per rendered cell or row. Everything else is amortised over the window, or memoized per pipeline stage.
The grid below counts two of them. Both hooks do nothing but increment a number, and the numbers climb every time the window moves. Scroll it, sort it, and watch how fast a hook that runs per cell adds up: whatever you put in there, you are paying for it at this rate.
// Three hooks run per rendered cell or row. They are the ones worth
// keeping cheap, because everything else is amortised over the window.
cellClass: ({ value }) => (value > 100 ? 'text-success' : '') // per cell
rowClass: (node) => (node.row.locked ? 'opacity-60' : '') // per row
cellDecoration: ({ node, column }) => undefined // per cell
// A grid whose features define no cellDecoration skips that pass
// entirely, so a feature you do not register costs nothing at all.
// The pipeline itself is memoized per stage with $derived: a filter that
// did not change is not recomputed when the sort does.