open atlas
↑ Back to track
Node.js, zero to senior NODE · 04 · 03

Worker threads and clustering

On one thread, async I/O scales but CPU-bound work blocks the event loop and starves every request. Offload CPU work to a worker_threads pool sized to your cores; spread a stateless network server across cores with cluster or replicas. Neither fixes pure I/O.

NODE Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

A thumbnail API ran fine at 50 req/s and fell over at 200. The CPU never hit 100% — it sat at ~30% on an 8-core box — yet p99 latency blew past 6 seconds and health checks started failing. The culprit was a single line: sharp(buf).resize(800).toBuffer() for some inputs decoded synchronously, and a 400 ms image transform on the main thread froze the event loop for 400 ms — no other request could be parsed, no timer could fire, no socket could be read, for every concurrent client at once. The fix wasn’t a bigger box; the box was 70% idle. It was that all the work was crammed onto one of eight available threads, and the other seven sat watching. By the end of this lesson you’ll know exactly when to reach for a worker pool, when to use cluster, and — just as importantly — when neither is needed.

The single-thread wall

Node runs your JavaScript on one thread. That is a feature for I/O: an await fetch() or await db.query() parks the work in libuv or the kernel and the event loop moves on, so one thread juggles thousands of concurrent connections at near-zero cost. The model breaks the instant work is CPU-bound — hashing a password with bcrypt, transforming an image, gzipping a payload, parsing a 20 MB JSON body, or a JSON.parse on a huge string. None of that yields to the loop. While it runs, the loop is blocked: no callbacks, no timers, no new requests, for every client, not just the one whose request triggered it.

The official guidance — Don’t Block the Event Loop — is the whole game. A 200 ms synchronous CPU task isn’t “200 ms slower for that user”; it adds 200 ms of head-of-line latency to every request already queued behind it. There are exactly two escapes, and they map to two different problems:

  • More threads in one processworker_threads — for CPU parallelism with optional shared memory.
  • More processescluster, or N replicas behind a load balancer — to use all cores for a network server.

Neither is for I/O. If your endpoint is slow because it awaits a database, a worker won’t help — the bottleneck is the database, and the loop was never blocked.

worker_threads: real threads, isolated memory

worker_threads gives you real OS threads inside one Node process, each with its own V8 isolate and event loop. You spawn one with a script and hand it data; it computes and posts a result back.

// main.js
import { Worker } from "node:worker_threads";

function hashInWorker(password) {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./hash-worker.js", { workerData: password });
    worker.on("message", resolve);
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) reject(new Error(`worker exited ${code}`));
    });
  });
}

// hash-worker.js
import { workerData, parentPort } from "node:worker_threads";
import bcrypt from "bcrypt";
parentPort.postMessage(bcrypt.hashSync(workerData, 12)); // CPU work, off the main loop

Two facts shape everything. First, memory is isolated. A worker does not share variables with the parent; every postMessage payload is structured-cloned, which costs CPU and memory proportional to size — sending a 50 MB buffer this way copies 50 MB. For large binary data you have two outs: transfer an ArrayBuffer in the message’s transfer list (zero-copy — ownership moves, the sender can no longer touch it), or share it for real with a SharedArrayBuffer plus Atomics for coordination, so multiple threads read and write the same bytes with no clone at all.

const ab = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ ab }, [ab]); // transfer list → zero-copy; ab is now detached here

Second, spawning a worker costs real money — a new thread, a new V8 isolate, megabytes of RAM and tens of milliseconds of startup. Spawning one per request is an anti-pattern that often costs more than the work it offloads. The correct shape is a pool: a fixed set of long-lived workers, sized roughly to the number of CPU cores, that pull jobs off a queue. Libraries like piscina implement this; the rule of thumb is pool size ≈ os.availableParallelism(), because more threads than cores just thrash the scheduler.

Pick the best fit

A single Node service does two things: it proxies a slow upstream API (mostly awaiting I/O) and it generates PDF reports (CPU-heavy, ~300 ms each, needs read access to a large in-memory product catalog). You must scale it across an 8-core box. What's the best fit?

cluster: many processes sharing one socket

cluster solves a different problem: scaling a network server across all cores. A primary process forks N worker processes, and they all share a single listening socket — incoming connections are distributed across the workers. On non-Windows platforms the default scheme is round-robin in the primary; otherwise the OS distributes via SO_REUSEPORT. Each worker is a full Node process with its own memory and its own event loop, so eight workers genuinely use eight cores for request handling.

import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import http from "node:http";

if (cluster.isPrimary) {
  for (let i = 0; i < availableParallelism(); i++) cluster.fork();
  cluster.on("exit", (worker) => cluster.fork()); // replace a dead worker
} else {
  http.createServer((req, res) => res.end("handled by " + process.pid)).listen(3000);
}

The defining constraint: processes do not share memory. An in-memory session map, a local rate-limit counter, or a process-level cache exists per worker — a request handled by worker A can’t see state worker B set. The fix is to move shared state to an external store (Redis, a database) and treat each worker as stateless. In modern deployments cluster is often superseded by running N container replicas behind a load balancer (or a process manager like PM2), which gives the same parallelism plus restart, rollout, and cross-host scaling — the same “stateless processes, shared store” discipline applies either way.

MechanismIsolationShared memory?Best for
worker_threadsthreads in one processyes — SharedArrayBuffer + Atomics, or zero-copy transferCPU work needing shared/large data in-process
clusterseparate processes, shared listen socketno — message-pass or external storescaling one stateless network server across cores
replicas / containersseparate processes, often separate hostsno — external store onlystateless service scaled past one box, with rollout/restart
Why this works

Why not just cluster everything to dodge worker_threads? Because the two solve orthogonal problems. cluster parallelizes connection handling — it’s perfect when every request is cheap and you just need more of them at once. But if a single request does 300 ms of CPU, cluster doesn’t help that request: it still blocks its own worker’s loop for 300 ms, stalling the other connections that worker is serving. worker_threads moves that one CPU chunk off the request’s loop entirely. Many production services use both: cluster/replicas for breadth, a worker pool inside each for the heavy CPU jobs.

Choosing, and the failure modes

The decision tree is short. Is the work I/O-bound? Do nothing — it’s already async, and adding threads or processes just adds overhead. Is it CPU work that needs shared or large in-process data? worker_threads pool. Is it scaling a stateless network server across cores? cluster or replicas.

// Pooled, not per-request — piscina sizes ≈ cores and reuses workers
import Piscina from "piscina";
const pool = new Piscina({ filename: "./resize-worker.js" });
app.post("/thumb", async (req, res) => {
  const out = await pool.run(req.body); // CPU work runs off the main loop, on a reused thread
  res.end(out);
});

Two failure modes recur. First: synchronous CPU work on the main thread. This is the hook — a 30%-idle box with a 6-second p99, because one thread is doing all the heavy lifting while seven cores idle. Symptom: event-loop lag spikes, timeouts and failed health checks under load that disappear at low traffic. Fix: offload the CPU work to a worker pool. Second: forgetting cluster workers don’t share memory. You ship in-memory sessions or a local cache, it works in dev (one process), and in production with eight workers a user’s session “randomly” vanishes one request in eight because a different worker handled it. Fix: move that state to a shared store and keep workers stateless.

Quiz

What do worker_threads share with the parent that cluster worker processes do NOT share with each other?

Quiz

Why spawn workers from a fixed pool instead of creating a new Worker per incoming request?

Order the steps

A /resize endpoint does the image transform synchronously on the main loop and tanks p99 under load. Order the steps to move it onto a worker pool:

  1. 1 Confirm it's CPU-bound: event-loop lag spikes while CPU sits idle and I/O isn't the bottleneck
  2. 2 Extract the transform into a standalone worker script that takes input and posts back the result
  3. 3 Create one fixed pool sized ≈ os.availableParallelism() (e.g. via piscina) at startup, not per request
  4. 4 Change the handler to await pool.run(input) instead of computing inline
  5. 5 Pass large buffers by transfer/SharedArrayBuffer to avoid structured-clone copies, and load-test p99 to confirm the loop stays free
Recall before you leave
  1. 01
    Your box is 30% CPU-idle yet p99 latency is multiple seconds under load. What is almost certainly happening, and what's the fix?
  2. 02
    When you scale a stateful server with cluster (or replicas) and forget that processes don't share memory, what breaks, and how do you fix it?
Recap

Node runs your JavaScript on one thread, which is ideal for I/O — an awaited fetch or query parks in libuv and the loop moves on — but fatal for CPU-bound work, because hashing, image/video transforms, compression, or parsing a huge body don’t yield, and while one runs the event loop is blocked for every client, not just the request that triggered it (the hook’s 30%-idle box with a 6-second p99). There are two escapes, mapped to two problems. worker_threads are real threads in one process with isolated memory but shared bytes via SharedArrayBuffer/Atomics or zero-copy ArrayBuffer transfer; because every postMessage is structured-cloned and spawning a thread costs RAM and startup, you use a fixed pool sized to the cores, never one worker per request. cluster forks N processes that share one listening socket (round-robin by default on non-Windows) to use all cores for a network server, but processes share no memory, so in-memory sessions or caches diverge per worker unless you push them to a shared store — the same rule that governs N container replicas behind a load balancer, which often supersede cluster in practice. Decide by the work: pure I/O needs neither; CPU work with shared in-process data wants a worker pool; a stateless network server wants cluster or replicas. Now when you see a 30%-idle box with a multi-second p99, you know the first question isn’t “do we need more servers?” — it’s “which CPU task is blocking the loop, and which thread should we move it to?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.