Performance
Every number here comes from a benchmark you can run yourself. Nothing is extrapolated.
node packages/datagrid-core/bench/pipeline.bench.mjs 1000000
Median of 5 runs, 1,000,000 rows × 15 columns, Node 24 on Windows. Your machine will differ; the ratios are what matter.
1,000,000 rows
| Operation | Row-based | Columnar (worker) | Speed-up |
|---|---|---|---|
| Sort — numeric | 589 ms | 90 ms | 6.6× |
| Sort — text | 1,496 ms | 108 ms | 13.8× |
| Sort — date | 620 ms | 123 ms | 5.0× |
| Sort — two columns | 1,661 ms | 192 ms | 8.7× |
| Filter — equality | 215 ms | 19 ms | 11.1× |
| Filter — 3-clause AND | 236 ms | 63 ms | 3.7× |
| Free-text search (all columns) | 852 ms | 58 ms | 14.8× |
| Filter + sort combined | — | 50 ms | — |
100,000 rows
Encoding costs 169 ms once; after that every operation is single-digit milliseconds: sorts 4–12 ms, filters 1–7 ms, search 6 ms.
Where the speed comes from
Columnar encoding. Columns become typed arrays — Float64Array for
numbers, dates and booleans; a string dictionary plus Int32Array codes for
text. Row objects are never copied to the worker; only the buffers are
transferred, which is a zero-copy handover.
Dictionary evaluation. A contains or equality filter on a text column is
evaluated against the column's distinct values, then reduced to an integer
scan. Work scales with cardinality, not row count.
Collator ranks. Text sorting compares precomputed integer ranks instead of
calling Intl.Collator inside the comparator. This is most of the 13.8×.
Radix sort. Numeric sorting uses an LSD radix sort over an order-preserving 64-bit key — eight linear passes with no comparator callbacks at all. A comparator sort of a million rows costs roughly 20 million JS callback invocations; this costs none. Multi-key sorting runs one stable pass per key.
Search elimination. If the search term appears in no dictionary entry of a
column, that column is skipped entirely. And since String(someNumber) can only
contain a fixed set of characters, a term with letters never scans numeric
columns. That took free-text search from 852 ms to 58 ms.
Quantized scrolling. The virtual window only changes when a row or column boundary is crossed, so most scroll events never reach React at all.
The worker
Above ~20,000 rows the pipeline moves off the main thread. Below that the main thread is genuinely faster — latency beats parallelism for small data.
The worker runs the same pipeline function the main thread would, so results are identical by construction rather than by convention. A suite of golden tests asserts that both paths produce byte-identical row orderings across every operator and data type.
If no Worker global exists (SSR, tests, restrictive environments) or the
worker fails to start, the grid degrades to the synchronous path instead of
breaking.
Rendering
Rows and columns are both virtualized. A 100,000-row grid keeps ~25 rows in the DOM; columns scrolled out of view are replaced by a single spacer element rather than being rendered.
Measured in a real browser at 100,000 rows: the scroll container reports a 2,800,032 px scroll height while the DOM holds 21–30 row elements, and the rendered block's transform lands exactly on the row grid.
The first-query encode
Before the first sort at large row counts, the grid encodes the columns into
typed arrays and transfers them to the worker. That encode has to run on the
main thread: it calls your valueGetter and calculateSortValue, and functions
cannot be sent to a worker.
At 100,000 rows the whole pass is 169 ms and unnoticeable. At 1,000,000 rows × 15 columns it is about 3.1 seconds of work — but it is not one 3.1-second freeze. The encode yields to the browser between columns, so the longest single task is one column:
| 1,000,000 rows × 15 columns | |
|---|---|
| Total encode work | ~3.1 s |
| Longest blocking task | ~0.55 s (the widest text column) |
| Tasks | 15, with a paint opportunity between each |
The total work is unchanged — the same rows still have to be read — but the page keeps painting and responding while it happens instead of going white.
Yielding is skipped below 100,000 rows, where the whole encode is a few milliseconds and the scheduling round trip would cost more than it saves.