Skip to content
Back to Homephamkhanhminhman.com / blog
Node.js / Concurrency• 25 Sept 2026• 9 min read

My worker_threads benchmark said 4× faster. On a server, that was the least important number.

My old study notes have a folder with three small files on Node.js worker threads. main.js counts to a billion five times on the main thread. index.js hands the same five jobs to six worker threads, and worker.js does the counting. I ran it once, saw the second number come out much smaller than the first, and took away the obvious lesson: worker threads make CPU-heavy work faster.

I ran it again this week and printed more than the final time. The speedup is real. But the split in the current version gives one worker nothing, the version I had commented out above it has a one-letter bug that made workers slower than no workers, the worker count is a guess, and the benefit that matters most on a server does not show up in this benchmark at all.

The setup: Node.js 22.21 on an Apple M4 laptop with 10 cores (4 performance, 6 efficiency), plus Node.js 20.20, 22.23 and 24.21 in Docker for the container part. Times are medians of three to five runs. Next to wall-clock time I report CPU time from process.cpuUsage(), which adds up every thread in the process. That is the number that shows wasted work. I repeated every measurement the next day in a fresh session; the results agreed within 10%, apart from sub-millisecond ping times and where the text says otherwise.

1. The benchmark as written: 4×, and one worker with nothing to do

The notes, run unchanged. Wall time is what the scripts print; CPU time is for the whole process, from time:

Runwall timeCPU time
main.js: 5 jobs on the main thread2,334 ms2.31 s
index.js: 5 jobs, 6 workers575 ms2.85 s

Four times faster. Then I printed what each worker had received:

const jobsPerWorker = jobs.length / numWorkers;   // 5 / 6 = 0.8333…

// worker 0: jobs.slice(0,      0.8333)  ->  slice(0, 0)  ->  []
// worker 1: jobs.slice(0.8333, 1.6667)  ->  slice(0, 1)  ->  [1e9]
// ...
// jobs per worker: [0, 1, 1, 1, 1, 1]

slice() truncates fractional indexes, so the first worker got an empty array, started up, and returned 0. The total was still right, 5,000,000,000, because the truncated ranges still cover all five jobs, so nothing looked wrong. The six workers were really five.

Timing each worker separately showed something else. Each job took 530–565 ms inside a worker, against about 465 ms on the main thread running alone. The same job runs slower when four other cores are busy at the same time, and the process spent 2.85 s of CPU on work that took 2.31 s on one thread. Five jobs on five cores gave 4×, not 5×.

2. The version I had commented out: one letter, 2.5 times the work

Above that code, commented out, was an earlier attempt that split the jobs with a helper:

function chunkify(array, n) {
  let chunks = [];
  for (let i = n; i > 0; i--) {
    chunks.push(array.slice(0, Math.ceil(array.length / i)));
  }
  return chunks;
}

It is meant to cut the array into n pieces. It never cuts anything. slice copies part of the array and leaves the array unchanged, so every pass starts again from index 0 of the full list, and the last chunk is always the whole array. It only does its job with splice, which removes the items it returns:

                     slice (as written)       splice
2 jobs, 4 workers    [1, 1, 1, 2]  = 5 jobs    [1, 1, 0, 0] = 2 jobs
8 jobs, 4 workers    [2, 3, 4, 8]  = 17 jobs   [2, 2, 2, 2] = 8 jobs

The notes used two jobs of a billion:

Runwall timeCPU time
2 jobs on the main thread915 ms914 ms
4 workers, chunkify with slice991 ms2,579 ms
4 workers, chunkify with splice486 ms1,001 ms

With the bug, four workers were slower than none. One of them counted both jobs by itself while the other three repeated work nobody had asked for. Had I stopped at that version, the note would have said worker threads are not worth it.

The benchmark could not catch it because it never printed its result. Each worker reported “completed” and the main thread printed a time. The later version adds up the counts; with this bug it would have printed 5,000,000,000 for two jobs of a billion, and the mistake would have been obvious in a second. Wall time hid it as well: 991 ms against 915 ms looks like worker overhead. CPU time, 2.8 times the main thread's, does not.

3. “The number of workers you want”: the cores decide, and os.cpus() does not know your limit

The notes set numWorkers = 6 with the comment “the number of workers you want to use”. So I varied it: the same 4.8 billion increments, split evenly as 480 jobs of ten million:

Workerswall timespeedupCPU time
main thread2,266 ms1.0×–
12,241 ms1.0×2,244 ms
21,155 ms2.0×2,314 ms
4640 ms3.5×2,556 ms
6506 ms4.5×2,914 ms
8415 ms5.5×3,211 ms
10394 ms5.8×3,420 ms
12390 ms5.8×3,466 ms
16398 ms5.7×3,563 ms
32447 ms5.1×4,019 ms

Close to linear up to two workers, and 3.5× at four, the number of performance cores. Each of the six efficiency cores adds less, and from eight workers on the differences are noise: over three sessions, 8, 10, 12 and 16 workers all finished in 390–455 ms, and the fastest of them changed between sessions. The middle rows are also the least stable; with a browser busy in the background, one session ran six workers in 615 ms instead of 506. Past the core count, more workers only cost more: 32 were slower than 10 and burned 1.8 times the CPU of one. On ten cores the speedup topped out near 5.8×. The ceiling is the number of cores, and not every core is equal.

In production the count often comes from os.cpus().length. In a container that number describes the machine, not your share of it. The Docker VM here has 2 CPUs:

                   os.cpus()         os.availableParallelism()
docker run         (any Node)     Node 20.20   Node 22.23   Node 24.21
--cpus=1               2               2            1            1
--cpus=1.5             2               2            1            1
--cpus=0.9             2               2            2            1
--cpus=0.5             2               2            2            1
--cpuset-cpus=0        2               1            1            1

os.availableParallelism() also reads the CPU quota from the cgroup on Node.js 22 and 24, which is why they get --cpus=1 right. Node.js 20 ships an older libuv that ignores the quota and only respects --cpuset-cpus. Node.js 22.23, the latest 22.x, ships libuv 1.51, which computes the quota as limit / period in integer arithmetic: 1.5 CPUs becomes 1, and anything below one CPU becomes 0, which the next check treats as “no limit”, falling back to the machine's count. libuv 1.52 turns that 0 into a 1, and Node.js ships it from 24.16. A Kubernetes CPU limit of 500m sets the same kind of quota, so on Node.js 22 I would expect the same fallback there; I have not tested it.

What oversubscribing costs under a one-CPU limit: one worker 474 ms, two workers (the count os.cpus() suggests) 518 ms, four workers 592 ms. Size a pool from os.availableParallelism(), print it once inside the container you deploy, and set the size yourself on Node.js 20, or on 22 when the limit is below one CPU.

4. What a worker buys on a server: an event loop that keeps answering

Everything so far measures how fast a batch finishes when the process has nothing else to do. A server always has something else to do. So I put the same billion-count behind an HTTP endpoint, /report (about 460 ms of CPU), next to a /ping that returns “ok”, sent eight reports at once, and pinged every 50 ms until they finished:

Where /report runsall 8 done/ping median/ping max
main thread3,661 ms1,774 ms3,559 ms
pool of 4 workers1,053 ms0.5 ms0.8 ms
pool of 8 workers664 ms0.8 ms5.2 ms

With the work on the main thread, the server was not only slow at reports. It was slow at everything: a request that does nothing waited 1.8 s at the median and 3.6 s at worst. Health checks with a two-second timeout would have started failing while the server was busy doing its job. Pinging every 10 ms instead of 50 was worse: 188 of 333 pings failed outright with ECONNRESET or EPIPE. macOS lets at most 128 connections wait to be accepted (kern.ipc.somaxconn), and while the event loop was busy nothing accepted them. With a pool of eight, none failed.

With a pool, the median /ping stayed under a millisecond and the reports finished sooner as well. This is the real reason to use worker threads in a web server, and a batch benchmark cannot show it. Each report still needs its 460 ms of CPU; what changes is that every other request stops waiting for it. The Node.js guide on the event loop makes the same argument; this is what it looks like in numbers.

5. What a worker costs: start-up, memory, and copying

A worker is not a lightweight thread. Each one is an operating-system thread running its own V8 isolate, with its own heap and event loop. On this machine:

new Worker() until its first message     ~11 ms    (median of 20)
memory per idle worker (RSS)             ~7.8 MB
small message, round trip                ~0.01 ms

That makes “start a worker per request” easy to write and expensive under load. Sixty-four reports at once:

Setupall 64 donefirst reportmedian reportpeak RSS
pool of 10 workers + queue4,757 ms692 ms2,775 ms146 MB
new Worker per request4,696 ms3,216 ms4,614 ms616 MB

Both kept all ten cores busy, so the batch took the same time. But 64 threads sharing 10 cores all finish near the end: the first report came back after 3.2 s instead of 0.7 s, the median after 4.6 s instead of 2.8 s, and memory went up 4.2 times. With a queue, the first reports are back in 0.7 s and the rest wait their turn. The pool size matters too: a pool of eight left two cores idle and needed 5.1–5.4 s for the same batch. Libraries such as Piscina give you the pool and the queue; what matters is having one, sized to the cores and created once at start-up.

The other cost is data. Workers do not share objects with the main thread: postMessage copies them with the structured clone algorithm. For data made of many small objects, the copy is the expensive part:

1,000,000 objects { id, sku, price }
  postMessage to a worker and back               ~350 ms
  main thread blocked inside postMessage         ~115 ms   (serialising)
  summing price on the main thread instead         ~8 ms

32 MB Float64Array
  copied                                         4–17 ms
  transferred (ownership moves to the worker)    ~0.1 ms
  SharedArrayBuffer                              ≤0.1 ms

Handing the million objects to a worker blocked the main thread for 115 ms, more than ten times as long as summing their prices in place. Send the worker an id or a query and let it load its own data, or pass numbers in typed arrays and transfer them.

6. Sometimes Node already has the threads

The last thing the notes missed: Node already runs some expensive work off the main thread. The asynchronous versions of crypto.pbkdf2, crypto.scrypt, zlib and the fs calls run on libuv's thread pool. Password hashing at login is the common case. PBKDF2-HMAC-SHA512 at OWASP's 220,000 iterations takes 38 ms here. Forty logins at once:

Setupall 40 donemedian login/ping max
pbkdf2Sync1,536 ms812 ms224 ms
pbkdf2, default pool of 4461 ms259 ms2.7 ms
pbkdf2, UV_THREADPOOL_SIZE=8322 ms194 ms2.5 ms

Dropping the Sync did what a worker pool would do, with no worker code. The catch is the size: four threads by default, shared with file system calls, dns.lookup and compression, so a burst of logins can hold up file reads and DNS lookups, and the other way round. UV_THREADPOOL_SIZE raises the limit; set it in the environment when the process starts.

What the notes say now

  • A benchmark prints its answer, not just its time. And CPU time next to wall time. The slice bug did 2.5 times the work and looked like overhead.
  • Check how the work was split before timing it. [0, 1, 1, 1, 1, 1] and [1, 1, 1, 2] both ran without an error.
  • Size the pool from os.availableParallelism(), checked inside the container, never from os.cpus().length. On Node.js 22 a limit below one CPU still falls back to the machine's count; Node.js 20 ignores the limit entirely.
  • On a server, the win is the event loop, not the speedup. /ping went from 1.8 s to 0.5 ms.
  • One pool with a queue, created at start-up. Not a worker per request.
  • Send ids or transferable buffers, not object graphs. Copying can cost more than the work.
  • Before writing a worker, look for the async API. pbkdf2 already runs off the main thread.

The 4× in my notes was correct, and it was the least useful thing that benchmark could tell me. The question for a server is not how fast one batch finishes on an idle machine. It is what everything else waits for while the batch runs.

Node.js / ConcurrencyBack to Home