Calin Gabriel Backend Developer · Node.js / TypeScript

← Lab Runs in your browser

The export that froze the page

On an energy-market platform, a daily export took 45 minutes and pinned the thread that was supposed to be serving everyone else. Moving it into a worker pool brought it to 12. This page does the same thing to this tab, so you can watch it happen and read the numbers yourself.

Live demo Web Workers node-worker-pool-bench ↗

One thread

Node runs your JavaScript on one thread. While that thread is hashing 200,000 rows, it is doing only that. It cannot answer a request, or a health check, or anything else, until the loop ends.

So a slow export is not only slow. For as long as it runs, the service is deaf. That is the part the 45 minutes does not tell you.

The fix, and the catch

Hand the heavy work to worker threads and the main thread stays free to answer everyone else. But how you hand it over decides whether that actually helps.

Send the rows themselves, and they have to be copied into the worker — and that copy is made on the main thread. You moved the hashing off and added copying on. Send a range instead, two numbers, and the worker builds its own slice while the main thread does nearly nothing.

Both finish faster than doing the work inline. Only one of them keeps answering.

Run it

A browser tab is built the same way: one thread for your code, Web Workers for everything else. So when the naive version runs, this page freezes — and that freeze is the same event as an API going quiet, except you can feel it.

Both buttons export the same rows and print the same fingerprint. One of them stops this page from responding while it does.

Export size

Loading the demo…

Show the middle case — copying rows into the pool

There is a third version, and it is the one worth knowing about. It still loads every row into memory, then hands each chunk to a worker. The hashing moves off the main thread; the cost of copying the rows there does not. In the Node benchmark that version finished 3.5× faster than inline and answered fewer health checks on time — 43.8% against 69.1%. Faster and less responsive, at the same time.

The whole difference

/**
 * Either the main thread paid to build and copy `rows` over, or it sent two
 * numbers and the worker builds its own slice. That is the entire difference
 * between the two pooled runs on this page.
 *
 * In a real export the second branch is a cursor, a file offset, or a
 * LIMIT/OFFSET query: the worker does its own reading.
 */
function resolveRows({ rows, range }) {
  if (rows) return rows;
  return makeRows(range.count, range.start);
}
01

The copy is not free

Sending rows to a worker means copying them, and the copy is made on the main thread — the exact thread you were trying to protect. Move the work off and you can find you have only changed which part of it blocks you.

02

A range is two numbers

Sending a start and a count costs nothing to hand over. The worker does its own reading: a cursor, a file offset, a LIMIT/OFFSET query. The rows never pass through the main thread at all.

The same numbers in Node

The browser demo is a port. The original is a Node benchmark with worker_threads, a test suite, and no dependencies. Its published run: 200,000 rows, 9 workers, median of three, on a MacBook.

555 ms
Inline, main thread — 69.1% on time
159 ms
Pool, rows copied — 43.8% on time
133 ms
Pool, range sent — 71.4% on time

Both run the same task.js. Building this page on Node v24.7.0 over 20,000 rows produced 20000:10142346, and your browser prints its own fingerprint on every run above. They have to match — and if they ever don't, the demo refuses to report a speedup rather than showing you a number it can't stand behind.

What this does not prove

The work is synthetic — a hash iterated enough times to cost something, chosen because it cannot be made to pause. It is not your workload, and something dominated by waiting on I/O rather than by CPU would not behave like this.

It is also one machine, once, with whatever else your laptop is doing. Your numbers will not match mine. The ratios should.