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:

OperationCost
Numeric sort101 ms
Text sort (cached)90 ms
Text sort (first query — builds a per-column rank index)1.8 s
Equality filter18 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:

OperationCost
Numeric sort5.7 ms
Text sort (cached)5.2 ms
Text sort (first query)147 ms
Equality filter1.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.