What actually blocks the main thread in a data grid
· 5 min read
Virtualize a grid and the DOM stops being the bottleneck: a million rows, twenty-some elements. That fix is real and it is not this post — we have already written about the rendering side.
It also does not touch the question this post is about. Rendering is not the only thing a grid does when you click a column header or type into a filter. Something still has to sort, filter or search the actual rows, and that work costs real milliseconds — sometimes real seconds — no matter how few of them ever reach the screen.
The number that matters is not "how many rows render"
It is "how long does the operation run, and on which thread."
At 1,000,000 rows, median of three runs, our engine's own numbers:
| Operation | Cost |
|---|---|
| Numeric sort | 101 ms |
| Text sort (cached) | 90 ms |
| Text sort (first query — builds a per-column rank index) | 1.8 s |
| Equality filter | 18 ms |
| Free-text search (cached) | 65 ms |
| Free-text search (first query — lower-cases the column) | 413 ms |
These are the fast numbers — the columnar path, typed arrays, no comparator callbacks. The method and the full table, including the row-based numbers this path replaced, are on the benchmarks page. None of them are zero. A filter that runs in 18 ms is imperceptible. A text sort that takes 1.8 s the first time a user touches that column is not — and if either one runs on the same thread that is supposed to be scrolling the grid and responding to the next click, the page is unresponsive for the entire span.
That is the whole argument for a worker. Not "workers are fast" — the numbers above are measured on a single core, no parallelism involved. The argument is narrower and harder to dismiss: wherever this work runs, input on that thread stops until it finishes. A worker does not make the sort faster. It makes the freeze happen somewhere the user cannot feel it.
Why the threshold is ~20,000 rows, not zero
Every postMessage has a cost, and structuring data for zero-copy transfer
is not free either. Below a certain size, the round trip to a worker and back
is slower than just doing the work in place — so our engine's own threshold
is deliberately not zero. Documented on the performance page:
above roughly 20,000 rows, the pipeline moves off the main thread; below it,
staying on the main thread is genuinely faster.
For a sense of scale, here are the same measured operations at 100,000 rows:
| Operation | Cost |
|---|---|
| Numeric sort | 5.7 ms |
| Text sort (cached) | 5.2 ms |
| Text sort (first query) | 147 ms |
| Equality filter | 1.8 ms |
Five milliseconds is not a UX problem for anyone. A hundred and forty-seven milliseconds, on a column you sort once and then leave alone, is borderline — noticeable if you are looking for it, forgettable if you are not. Ten times the rows made that first text sort about twelve times slower (1.8 s), because ranking the distinct values is itself a sort, and every value in this column is distinct. That is the range where "which thread does this run on" stops being a detail.
The 20,000 figure is not read off this table. It is the row count below which the round trip to the worker costs more than the work itself, so smaller grids stay on the main thread.
What "blocked" looks like from the user's side
Not a spinner. A spinner is a UI that is still running and choosing to show you it is busy. A blocked main thread cannot run the code that draws the spinner either.
What it actually looks like: a scrollbar that stops moving mid-drag, a filter input where the letters you type do not appear until the work is done and then arrive all at once, a hover state that does not update, a tab that Chrome's own "page unresponsive" dialog will offer to kill if the block runs long enough. None of these read to a user as "sorting a column." They read as "broken."
The 1.8-second first text sort above is the sharpest example precisely because it is not the median case — it is the one operation in this table that, run on the wrong thread, crosses from "a little janky" into "did this just crash."
The part that is not free either
Moving the work off the main thread only helps if getting the data to the
worker does not itself cost main-thread time. Row objects are not cheap to
hand across that boundary: postMessage makes a structured clone of whatever
you send, and cloning a million JavaScript objects is itself a long task on the
sending thread.
That is the reason the engine encodes columns into typed arrays first
(Float64Array for numbers, a dictionary + Int32Array for text) and
transfers the underlying buffers rather than copying anything: a zero-copy
handover, not a serialize-and-clone. That encode pass has its own cost, and
its own story about not blocking the thread it runs on — covered on the
performance page, out of
scope here.
What this means if you are building or evaluating one
Ask two questions, not one.
"Does it use a worker?" is not enough by itself. A grid can use a worker and still do the handover on the main thread, or move sorting off it while filtering and search stay synchronous.
"What does it cost, and where does that cost land?" is the question that actually predicts whether your users notice. Ask for numbers, and for a way to reproduce them. Ours is a script you can run against the published package.
The setup for this — no workerUrl, no file to copy into public/ — is
described in how we embedded the worker in the npm package.
The full measured table, the machine and the method are on the benchmarks
page.