Ch. 6 · Node.js

Node.js Worker Threads and CPU Parallelism

On Node 22 a worker cut main-thread lag from 455 ms to 1 ms, yet a new worker per tiny task was 550x slower. Build a bounded pool instead.

~7 min readintermediateupdated Oct 7, 2026

Node runs your JavaScript on one thread. That’s fine for a typical API server, because most of a request’s time is spent waiting on databases and other services, and that waiting doesn’t occupy the thread. It stops being fine when a request needs real computation: resizing an image, parsing a 50 MB spreadsheet, hashing, compressing, running a big regex. While that code runs, every other request on the process waits.

worker_threads lets you run JavaScript on additional threads, each with its own V8 isolate, its own event loop and its own heap. The interview questions are rarely “what is a worker”; they’re “when would you use one, and what goes wrong?” The numbers below were measured on an 8-core Apple M1 with Node 22.

What blocking the main thread looks like

The test task is CPU-bound: 400,000 rounds of SHA-256 over a 1 KB buffer, written to stand in for any JavaScript computation. While it runs, a 10 ms setInterval records how late it fires, which is a direct measure of how long other requests would have waited.

// hash-worker.mjs
import { parentPort } from 'node:worker_threads';
import { createHash } from 'node:crypto';

export function work(n) {
  let buf = Buffer.alloc(1024, 1);
  for (let i = 0; i < n; i++) buf = Buffer.concat([createHash('sha256').update(buf).digest(), buf.subarray(32)]);
  return buf.subarray(0, 8).toString('hex');
}

parentPort?.on('message', ({ id, n }) => {
  if (n < 0) throw new Error(`bad input ${n}`);
  parentPort.postMessage({ id, result: work(n) });
});
JavaScript
// run the same task inline, then in a worker
work(N);

const w = new Worker(new URL('./hash-worker.mjs', import.meta.url));
await new Promise((resolve) => { w.once('message', resolve); w.postMessage({ id: 1, n: N }); });
JavaScript
inline: worst timer lag 455 ms
inline: task took 454 ms
worker: task took 488 ms including startup
worker: worst timer lag 1 ms
Text

The worker didn’t make the task faster. It took 488 ms against 454 inline, because it also had to start up. What changed is that the main thread stayed free: timer lag went from 455 ms to 1 ms. That’s the first thing to say in an interview: a worker buys responsiveness for one task and throughput across many tasks, not speed for a single task.

The second thing is what workers aren’t for. Reading files, querying a database or calling an HTTP API is already concurrent in Node: the thread starts the operation and moves on. Putting that in a worker adds overhead and solves nothing. Likewise, some CPU-heavy built-ins such as crypto.pbkdf2(), crypto.scrypt() and the async zlib functions already run on libuv’s thread pool when you use their callback or promise forms. Workers are for your JavaScript that computes.

Startup cost and task size

A worker isn’t free to create. It needs a new isolate, its own copy of the module graph, and a message channel. To see what that means for small tasks, here are 100 tiny tasks (10 hash rounds each) run three ways:

100 tiny tasks inline:             3.3 ms
100 tiny tasks, new worker each:   1829.2 ms
100 tiny tasks, one reused worker: 5.6 ms
Text

A new worker per task was about 550 times slower than doing the work inline: roughly 18 ms of startup and teardown wrapped around a task that takes 0.03 ms. Even with a reused worker, the round trip cost more than the work. The rule that follows is about granularity. A task is worth sending to a worker only if it takes noticeably longer than the messaging overhead, and only if the workers already exist. If you have thousands of tiny tasks, batch them into fewer messages.

Copying versus transferring data

postMessage uses the structured clone algorithm: the data is serialised on the sending thread and rebuilt on the receiving one. For small objects that’s cheap. For big buffers it isn’t:

const buf = new ArrayBuffer(200 * MB);
await roundTrip(buf, []);      // copy
await roundTrip(buf, [buf]);   // transfer list
JavaScript
copy     200 MB: 197.0 ms, byteLength in main thread afterwards = 209715200
transfer 200 MB: 0.7 ms, byteLength in main thread afterwards = 0
Text

Copying a 200 MB buffer made the round trip take almost 200 ms. Timing the postMessage call on its own showed it blocked the main thread for 94 ms while serialising, which is the very problem the worker was supposed to solve. Listing the buffer in the transfer list moves ownership instead: the memory is handed to the worker, and the main thread’s ArrayBuffer becomes detached, with byteLength 0. Touching it afterwards is a bug, so transfer only what you’re finished with. For data both sides need at once, SharedArrayBuffer gives real shared memory, coordinated with Atomics, but that brings the usual shared-memory hazards and is rarely needed in a web service.

A trap here: a small Node Buffer is often a view into a shared 8 KB pool. Buffer.from('hello').buffer has a byteLength of 8192, and putting it in a transfer list throws DataCloneError: Cannot transfer object of unsupported type. Large buffers and anything made with Buffer.alloc get their own memory, but check buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength before transferring buf.buffer.

A bounded pool

Putting those lessons together gives a pool: a fixed set of long-lived workers, a queue in front of them, and bookkeeping that ties each response to the task that caused it.

import { Worker } from 'node:worker_threads';

export class WorkerPool {
  #idle = []; #queue = []; #busy = new Map(); #nextId = 0;

  constructor(file, { size, maxQueue }) {
    this.file = file; this.maxQueue = maxQueue;
    for (let i = 0; i < size; i++) this.#spawn();
  }

  #spawn() {
    const w = new Worker(this.file);
    w.on('message', ({ id, result }) => this.#settle(w, (task) => task.resolve(result)));
    w.on('error', (err) => this.#settle(w, (task) => task.reject(err), true));
    w.on('exit', (code) => {
      if (code !== 0 && this.#busy.has(w)) this.#settle(w, (t) => t.reject(new Error(`worker exited with ${code}`)), true);
    });
    this.#idle.push(w);
  }

  #settle(w, fn, crashed = false) {
    const task = this.#busy.get(w);
    this.#busy.delete(w);
    if (task) fn(task);
    if (crashed) { this.#idle = this.#idle.filter((x) => x !== w); this.#spawn(); }
    else this.#idle.push(w);
    this.#drain();
  }

  #drain() {
    while (this.#idle.length && this.#queue.length) {
      const w = this.#idle.pop(), task = this.#queue.shift();
      this.#busy.set(w, task);
      w.postMessage({ id: task.id, n: task.n });
    }
  }

  run(n) {
    if (this.#queue.length >= this.maxQueue) return Promise.reject(new Error('pool queue full'));
    return new Promise((resolve, reject) => {
      this.#queue.push({ id: this.#nextId++, n, resolve, reject });
      this.#drain();
    });
  }

  close() { return Promise.all([...this.#idle, ...this.#busy.keys()].map((w) => w.terminate())); }
}
JavaScript

Each worker handles one task at a time, so #busy maps a worker to its single in-flight task. Then three experiments: eight 100,000-round tasks inline versus on a four-worker pool; twenty tasks submitted at once, one of them invalid, against a queue limit of 8; and a final task after the crash.

8 tasks inline:        895 ms
8 tasks on 4 workers:  428 ms (includes worker startup)
{ ok: 11, 'bad input -1': 1, 'pool queue full': 8 }
after crash, pool still works: e71d112c3060197c
Text

Four workers gave a 2.1x speed-up, not 4x. Startup is included, and the M1’s eight cores are four fast and four efficiency cores, so the parallel part isn’t perfectly even. Expect less than linear scaling and measure on the hardware you deploy to.

The tally shows the pool’s policies working. Four tasks went straight to workers and eight waited in the queue. The other eight were rejected immediately with pool queue full. That’s deliberate: the queue is bounded because an unbounded queue just moves the memory problem off the CPU. During a traffic spike, accepting every job means holding every job’s input (perhaps a 10 MB image each) in memory while it waits. Rejecting early lets the HTTP layer return a 503 with Retry-After, which a client or load balancer can act on.

The invalid task threw inside the worker. An uncaught exception in a worker kills that worker and emits 'error' on the main thread, so the pool rejects exactly the task that worker owned, starts a replacement and carries on; the final call shows the pool still serving. Without the #busy map, that task’s promise would never settle, and its HTTP request would hang until it timed out.

Sizing: CPU-bound pools rarely benefit from more workers than os.availableParallelism() minus one, leaving a core for the main thread. In containers, use the CPU limit, not the host’s core count. Each worker also has its own heap (often tens of MB), so resourceLimits and the worker count both affect memory. In production most teams use a library such as Piscina rather than writing this by hand, but the interview expects you to know what it does.

Interview practice

An image-thumbnail endpoint in Node takes about 300 ms of CPU per upload. Under load, health checks start timing out and Kubernetes restarts the pod. A teammate proposes wrapping the resize in new Worker(...) per request. What would you check and build?

Answer

The health-check failures confirm the main thread is blocked: health checks are cheap, so if they time out, the event loop isn’t getting to them. Moving the resize off the main thread is the right direction, but a worker per request isn’t. Each upload would pay worker startup, and there would be no limit on concurrency, so a spike of 200 uploads creates 200 threads competing for a few cores, each holding its image in memory. The pod would still be overloaded, just with a different symptom.

I’d use a pool sized to the pod’s CPU limit (a 2-CPU pod might get one or two workers), with a bounded queue. When the queue is full, the endpoint returns 503 with Retry-After instead of accepting work it can’t finish. I’d transfer the upload’s ArrayBuffer to the worker rather than copying it, give each job a timeout so a stuck resize is terminated and its worker replaced, and make sure a crashed worker rejects its job instead of leaving the request hanging. To verify, I’d load-test while measuring event-loop delay with monitorEventLoopDelay(), health-check latency, queue depth and p99 upload latency. If p99 is dominated by queue time, the pod needs more CPU or more replicas, not more workers.

Further reading

More in Node.js

read ✓Node.js · easy

Node.js Blocking Work and Request Latency

Why a 170 ms synchronous loop in one Node.js request delays every other request, how to measure loop delay, and when to chunk or use workers.

~6 min readread →
read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
esc