Node.js, part 4: worker threads — real parallelism for CPU-bound work
Part 4from the Node.js series · 24 parts in all
Node's single thread is a feature until someone hands it a CPU-bound job. A tight loop
blocks the event loop, so every other request waits — and async/await
cannot help, because there is nothing to await. Worker threads (Node 10.5, stable later) are
the answer: real OS threads, real cores, sharing nothing but a message channel.
The smallest useful worker
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
// The worker loads this same file and takes the other branch.
const worker = new Worker(__filename, { workerData: { n: 40 } });
worker.on('message', (result) => console.log('fib(40) =', result));
worker.on('error', (err) => console.error('worker failed', err));
worker.on('exit', (code) => console.log('worker exited', code));
} else {
const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
parentPort.postMessage(fib(workerData.n));
}
The event loop is free while that runs. That is the whole point, and it is worth measuring
once for yourself: the same fib(40) on the main thread makes a trivial HTTP
server answer in tens of seconds, and in a worker it answers instantly.
What crosses the boundary
Messages are structured-cloned, not shared: no functions, no class instances with behaviour, no live references. Two exceptions matter in practice:
- Transferable objects. An
ArrayBuffercan be handed over rather than copied — the sender loses access, the receiver gets it with no copy. That turns "stream a 200 MB buffer to a worker" into a pointer move. - SharedArrayBuffer. Genuinely shared memory, which is what
Atomicsexists for. Powerful, and easy to get wrong; treat it as the advanced case.
When a pool is the right shape, and when to look elsewhere
A worker per request is expensive — startup is milliseconds and each one costs memory.
For a service, keep a pool sized to os.cpus().length and queue work to it. But
check the premise first: if the job is I/O-bound, promises already overlap it and a
worker adds overhead for nothing. Workers are for CPU: image resizing, parsing, compression,
crypto, report generation. Next: the module system itself, and the seven-year migration to
import.