Customization

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.

1,000 rows loaded
Email
Role
Country
Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
Poland
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
Brazil
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
Nigeria
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
Brazil
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
Vietnam
$129,812.00
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Analyst
France
$72,011.00
Mai Nguyen
mai.nguyen7@example.com
Support
Analyst
Brazil
$83,226.00
Bruno Novak
bruno.novak8@example.com
Support
Analyst
Poland
$71,507.00
Grace Dubois
grace.dubois9@example.com
Support
Manager
Vietnam
$94,212.00
Tuan Ivanov
tuan.ivanov10@example.com
Design
Engineer
Japan
$105,520.00
Bruno Andersen
bruno.andersen11@example.com
Growth
Support
Brazil
$62,389.00
Bruno Yilmaz
bruno.yilmaz12@example.com
Core
Designer
Japan
$139,212.00
Grace Costa
grace.costa13@example.com
Platform
Support
Brazil
$96,734.00
Niko Muller
niko.muller14@example.com
Core
Manager
Japan
$76,769.00
Olga Yilmaz
olga.yilmaz15@example.com
Platform
Analyst
Germany
$66,068.00
Dara Okafor
dara.okafor16@example.com
Platform
Support
Poland
$87,888.00
Quyen Silva
quyen.silva17@example.com
Design
Manager
Japan
$121,817.00
Grace Haddad
grace.haddad18@example.com
Support
Designer
France
$137,633.00
Keiko Novak
keiko.novak19@example.com
Core
Analyst
Germany
$125,071.00
Tuan Dubois
tuan.dubois20@example.com
Growth
Engineer
Vietnam
$73,645.00
1,000 rows
// 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.

Metric100k rows500k rows1M rows
Data into the grid219ms251ms416ms
JS heap100MB315MB472MB
DOM nodes779779779
Scroll, median frame19ms23ms35ms

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.

OperationTime
Sort by number18ms
Sort by string265ms
Multi-sort, string and number264ms
Quick filter, per keystroke6ms
Build row nodes2ms

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.

LimitWhat it means
Scrolling holds 60fps to about half a million rowsIt 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 afterThe 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 columnsOnly 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 scaledEvery 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 treeO(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 listOne pass per spanning column on every sort or filter. Fine for report-shaped data, costly at a million rows
Row reorder rewrites dataAn 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.

not jumped yet
rowHeight: 40
Name
Email
Team
Hoang Kowalski
hoang.kowalski1@example.com
Design
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Bruno Dubois
bruno.dubois3@example.com
Design
Farid Haddad
farid.haddad4@example.com
Growth
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Mai Nguyen
mai.nguyen7@example.com
Support
Bruno Novak
bruno.novak8@example.com
Support
Grace Dubois
grace.dubois9@example.com
Support
Tuan Ivanov
tuan.ivanov10@example.com
Design
Bruno Andersen
bruno.andersen11@example.com
Growth
Bruno Yilmaz
bruno.yilmaz12@example.com
Core
Grace Costa
grace.costa13@example.com
Platform
Niko Muller
niko.muller14@example.com
Core
Olga Yilmaz
olga.yilmaz15@example.com
Platform
Dara Okafor
dara.okafor16@example.com
Platform
Quyen Silva
quyen.silva17@example.com
Design
Grace Haddad
grace.haddad18@example.com
Support
Keiko Novak
keiko.novak19@example.com
Core
Tuan Dubois
tuan.dubois20@example.com
Growth
5,000 rows
getRowHeight: () => 'auto'
Name
Email
Team
Hoang Kowalski
hoang.kowalski1@example.com
Design
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Bruno Dubois
bruno.dubois3@example.com
Design
Farid Haddad
farid.haddad4@example.com
Growth
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Mai Nguyen
mai.nguyen7@example.com
Support
Bruno Novak
bruno.novak8@example.com
Support
Grace Dubois
grace.dubois9@example.com
Support
Tuan Ivanov
tuan.ivanov10@example.com
Design
Bruno Andersen
bruno.andersen11@example.com
Growth
Bruno Yilmaz
bruno.yilmaz12@example.com
Core
Grace Costa
grace.costa13@example.com
Platform
Niko Muller
niko.muller14@example.com
Core
Olga Yilmaz
olga.yilmaz15@example.com
Platform
Dara Okafor
dara.okafor16@example.com
Platform
Quyen Silva
quyen.silva17@example.com
Design
Grace Haddad
grace.haddad18@example.com
Support
Keiko Novak
keiko.novak19@example.com
Core
Tuan Dubois
tuan.dubois20@example.com
Growth
5,000 rows
// 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.

not filtered yet
6 visible columns
Name
Email
Team
Role
Country
Salary
Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
Poland
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
Brazil
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
Nigeria
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
Brazil
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
Vietnam
$129,812.00
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Analyst
France
$72,011.00
Mai Nguyen
mai.nguyen7@example.com
Support
Analyst
Brazil
$83,226.00
Bruno Novak
bruno.novak8@example.com
Support
Analyst
Poland
$71,507.00
Grace Dubois
grace.dubois9@example.com
Support
Manager
Vietnam
$94,212.00
Tuan Ivanov
tuan.ivanov10@example.com
Design
Engineer
Japan
$105,520.00
Bruno Andersen
bruno.andersen11@example.com
Growth
Support
Brazil
$62,389.00
Bruno Yilmaz
bruno.yilmaz12@example.com
Core
Designer
Japan
$139,212.00
Grace Costa
grace.costa13@example.com
Platform
Support
Brazil
$96,734.00
Niko Muller
niko.muller14@example.com
Core
Manager
Japan
$76,769.00
Olga Yilmaz
olga.yilmaz15@example.com
Platform
Analyst
Germany
$66,068.00
Dara Okafor
dara.okafor16@example.com
Platform
Support
Poland
$87,888.00
Quyen Silva
quyen.silva17@example.com
Design
Manager
Japan
$121,817.00
Grace Haddad
grace.haddad18@example.com
Support
Designer
France
$137,633.00
Keiko Novak
keiko.novak19@example.com
Core
Analyst
Germany
$125,071.00
Tuan Dubois
tuan.dubois20@example.com
Growth
Engineer
Vietnam
$73,645.00
20,000 rows
2 visible columns
Name
Email
Hoang Kowalski
hoang.kowalski1@example.com
Bruno Nguyen
bruno.nguyen2@example.com
Bruno Dubois
bruno.dubois3@example.com
Farid Haddad
farid.haddad4@example.com
Jonas Yilmaz
jonas.yilmaz5@example.com
Quyen Tanaka
quyen.tanaka6@example.com
Mai Nguyen
mai.nguyen7@example.com
Bruno Novak
bruno.novak8@example.com
Grace Dubois
grace.dubois9@example.com
Tuan Ivanov
tuan.ivanov10@example.com
Bruno Andersen
bruno.andersen11@example.com
Bruno Yilmaz
bruno.yilmaz12@example.com
Grace Costa
grace.costa13@example.com
Niko Muller
niko.muller14@example.com
Olga Yilmaz
olga.yilmaz15@example.com
Dara Okafor
dara.okafor16@example.com
Quyen Silva
quyen.silva17@example.com
Grace Haddad
grace.haddad18@example.com
Keiko Novak
keiko.novak19@example.com
Tuan Dubois
tuan.dubois20@example.com
20,000 rows
// 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.

cellDecoration ran 0 times rowClass ran 0 times
Email
Role
Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Analyst
Mai Nguyen
mai.nguyen7@example.com
Support
Analyst
Bruno Novak
bruno.novak8@example.com
Support
Analyst
Grace Dubois
grace.dubois9@example.com
Support
Manager
Tuan Ivanov
tuan.ivanov10@example.com
Design
Engineer
Bruno Andersen
bruno.andersen11@example.com
Growth
Support
Bruno Yilmaz
bruno.yilmaz12@example.com
Core
Designer
Grace Costa
grace.costa13@example.com
Platform
Support
Niko Muller
niko.muller14@example.com
Core
Manager
Olga Yilmaz
olga.yilmaz15@example.com
Platform
Analyst
Dara Okafor
dara.okafor16@example.com
Platform
Support
Quyen Silva
quyen.silva17@example.com
Design
Manager
Grace Haddad
grace.haddad18@example.com
Support
Designer
Keiko Novak
keiko.novak19@example.com
Core
Analyst
Tuan Dubois
tuan.dubois20@example.com
Growth
Engineer
2,000 rows
// 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.