Worker Threads in Node.js: When to Pull CPU-Heavy Work Off the Event Loop Without Blocking the Rest of the Service
Node.js handles I/O concurrency without needing explicit threads. When a handler is waiting on a database response, a call to another service, or a disk read, async/await frees up the event loop for the duration of that wait, and the same thread keeps handling every other incoming request. That covers most of what a typical HTTP service does, because most of what it does is wait on something external. The trouble starts with the other kind of work, the kind that isn't waiting on anything: hashing a password with bcrypt, generating a PDF, resizing an image, parsing a multi-megabyte JSON payload. That code keeps the CPU busy continuously while it runs and never yields control in between, so for as long as it runs, the entire event loop is blocked. No other request gets processed until it's done — not even something as trivial as a health check.
Why async/await doesn't help here
async/await is syntactic sugar over promises, and a promise doesn't run anything in parallel on its own. If the work inside an async function is synchronous and heavy, marking the function async changes nothing: the block still runs on the same thread, start to finish, without yielding to the event loop at any point along the way.
// This does NOT free the event loop while it runs
app.post('/reports', async (req, res) => {
const data = await fetchData(req.body.id); // genuinely async
const pdf = generateHeavyPdf(data); // pure CPU, synchronous
res.type('application/pdf').send(pdf);
});
fetchData releases the thread while it waits on the database. generateHeavyPdf doesn't wait on anything: it computes, draws, and compresses, all within the same call stack. While it runs, any other request that hits the server — including one that only needs the health check — sits in the queue unattended, because the one JavaScript thread is busy with the PDF.
Isolating the work with worker_threads
The worker_threads module, part of the runtime since Node 10 and stable for several versions now, spins up real OS threads, each with its own V8 engine and its own event loop, isolated from the main thread except for whatever communication channels get opened explicitly. The heavy work moves into the worker, and the main thread stays free to keep serving requests while the computation runs in parallel.
// worker-pdf.js
const { parentPort, workerData } = require('worker_threads');
const pdf = generateHeavyPdf(workerData.data);
parentPort.postMessage(pdf, [pdf.buffer]); // transferable: no buffer copy
// main.js
const { Worker } = require('worker_threads');
function generatePdfInWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker-pdf.js', { workerData: { data } });
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`worker exited with code ${code}`));
});
});
}
Two details here matter more than the rest of the code. The first is how data actually travels between threads: by default, postMessage uses the structured clone algorithm, which copies the full value from one thread to the other. For a small object that's nothing; for a multi-megabyte buffer, the copy is a real cost, doubled because it happens both on the way in and on the way back with the result. The alternative is passing a second argument to postMessage listing which objects are transferable — typically ArrayBuffer — which moves ownership of the buffer to the other thread instead of copying it. The sending thread loses access to that buffer; the receiving thread gets it with zero copy.
The second detail is that a Worker isn't free to create. Spinning up a new V8 context with its own heap has a startup cost that, while small in absolute terms, stops being negligible if you pay it on every request. The pattern we use in production isn't one worker per request — it's a fixed-size pool of workers kept alive, with a task queue that hands work out to whichever one is free:
// pool.js
const { Worker } = require('worker_threads');
const os = require('os');
class WorkerPool {
constructor(script, size = os.availableParallelism() - 1) {
this.script = script;
this.idle = Array.from({ length: size }, () => new Worker(script));
this.queue = [];
}
run(data) {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this._dispatch();
});
}
_dispatch() {
if (this.queue.length === 0 || this.idle.length === 0) return;
const worker = this.idle.pop();
const { data, resolve, reject } = this.queue.shift();
const onMessage = (result) => {
cleanup();
this.idle.push(worker);
resolve(result);
this._dispatch();
};
const onError = (err) => {
cleanup();
// a worker that errored doesn't return to the pool: it gets replaced
this.idle.push(new Worker(this.script));
reject(err);
this._dispatch();
};
const cleanup = () => {
worker.off('message', onMessage);
worker.off('error', onError);
};
worker.once('message', onMessage);
worker.once('error', onError);
worker.postMessage(data);
}
}
We size the pool at availableParallelism() - 1, leaving one core free for the main thread and the rest of the system's processes; that margin matters more on a low-core machine than on a large one.
What didn't work: one worker per request
The first version of this service didn't use a pool: every PDF-generation request created its own Worker, used it once, and threw it away. That worked fine under light test traffic, with requests arriving spaced out. The problem showed up under real bursts, when several PDF requests landed close together: each one triggered a brand-new V8 context, and that creation competes for CPU with the workers already running, on top of the main thread itself. The result wasn't a visible error — it was latency that grew out of proportion right at the traffic peaks, which is the worst possible moment for a service to slow down. Switching to a fixed-size pool with a queue didn't just remove the repeated creation cost; it also capped how much CPU-bound work could run in parallel at any given time, instead of letting it grow unbounded with incoming traffic.
When worker_threads is the wrong tool
worker_threads shares a process with the main thread: same OS memory space, same process memory limit, and if a worker enters an infinite loop or leaks memory without bound, it can degrade the whole process even though the threads are technically isolated inside V8. When the heavy work needs real isolation — separate memory, the ability to kill the job without touching the parent process, or calling out to a different binary or runtime entirely, like a Python script running a machine learning model — the right tool is child_process, not worker_threads. A child process has its own OS-level memory space; if it hangs or runs out of memory, the parent stays alive and can simply kill it and start a new one.
The other distinction worth keeping straight is against the cluster module. cluster spreads incoming requests across several Node processes to use multiple cores at the whole-server level, but each process still runs its own single-threaded event loop, so a CPU-bound task inside a cluster process still blocks that process while it runs. cluster scales the whole service out; worker_threads isolates one specific heavy operation inside a service that's otherwise I/O-bound. The two combine without conflict: several cluster processes serving traffic, each with its own worker pool for the slice of work that genuinely eats CPU.
The rule we apply
Before moving anything to a worker, we ask whether the blocking is actually real: if the task takes a few milliseconds, the cost of serializing the data, creating or reusing a worker, and waiting for the message back can outweigh the benefit of not blocking the main thread. Worker threads earn their place when the synchronous computation runs long enough for other requests to notice the wait, and when the result can cross from one thread to the other without needing to share mutable state in real time. If the work needs memory isolation, can fail catastrophically, or runs on a different runtime than Node's own, the right isolation boundary is a separate process, not a thread inside the same one.