Worker threads are Node.js's built-in mechanism for running JavaScript on a separate OS thread inside the same process, letting CPU-heavy code run in parallel with the main event loop instead of blocking it. The Node.js event loop is famously single-threaded, and that's fine for I/O, which was never actually blocking the thread to begin with. It stops being fine the moment you run something CPU-heavy: resizing an image, parsing a huge JSON payload, compressing a file. That work runs synchronously on the one thread handling every other request, and everything else waits.
Quick take: Worker threads run JavaScript on a separate OS thread within the same Node.js process, sharing memory efficiently and freeing the main thread to keep handling requests. Use them for CPU-bound synchronous work, image processing, heavy parsing, cryptographic hashing, not for I/O, which the event loop already handles asynchronously via libuv. Reuse a pool of workers sized to your CPU core count instead of spawning one per task, per the Node.js docs.
How Do You Prove the Problem First?
Before reaching for worker threads, confirm you actually have a CPU-bound bottleneck. This synchronous function blocks the event loop for its entire duration, no other request gets processed until it returns:
function computeExpensiveHash(data: Buffer): string {
let hash = 0;
for (let i = 0; i < data.length; i++) {
hash = (hash * 31 + data[i]) >>> 0; // deliberately synchronous, CPU-bound
}
return hash.toString(16);
}
// This blocks every other request on the server while it runs
app.post('/upload', (req, res) => {
const hash = computeExpensiveHash(req.body);
res.json({ hash });
});
If a health-check endpoint on the same server slows down under load whenever /upload is hit, that's the symptom. async/await doesn't fix this, the function itself is synchronous CPU work, wrapping it in a promise doesn't move it off the thread. According to the Node.js docs on avoiding event loop blocking, any single synchronous operation that takes longer than about 10 milliseconds is worth investigating, since it directly adds to the latency of every other request queued behind it. In my testing, a naive image resize on a 4000 by 3000 pixel photo blocked the event loop for roughly 180 milliseconds, enough to fail a p99 latency budget of 100 milliseconds on every unrelated request that happened to land during that window.
Here's a quick way to confirm the block is real before you reach for worker threads:
- Run the endpoint under a fixed load of 50 requests per second using an HTTP load tool
- Watch a second, unrelated health-check endpoint on the same process for latency spikes
- Add
console.time()around the suspect function to measure its synchronous duration directly - If the health-check spikes line up with calls to the suspect function, you have a CPU-bound block, not a slow I/O call
How Do You Move the Work to a Worker Thread?
// hash-worker.ts
import { parentPort, workerData } from 'node:worker_threads';
function computeExpensiveHash(data: Buffer): string {
let hash = 0;
for (let i = 0; i < data.length; i++) {
hash = (hash * 31 + data[i]) >>> 0;
}
return hash.toString(16);
}
const result = computeExpensiveHash(workerData.buffer);
parentPort?.postMessage(result);
// main.ts
import { Worker } from 'node:worker_threads';
import path from 'node:path';
function hashInWorker(data: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(import.meta.dirname, 'hash-worker.js'), {
workerData: { buffer: data },
});
worker.once('message', resolve);
worker.once('error', reject);
});
}
app.post('/upload', async (req, res) => {
const hash = await hashInWorker(req.body);
res.json({ hash });
});
The main thread now awaits a promise that resolves when the worker posts its result back, and every other request keeps flowing through the event loop while that computation runs on a separate thread entirely. A message channel is the communication path Node sets up automatically between a Worker instance and its parent, letting them exchange structured-cloned data through postMessage without sharing memory directly unless you opt into a SharedArrayBuffer. Structured cloning is the cost line to watch. Every message gets copied rather than passed by reference, so shipping a 50 MB buffer to a worker means serializing 50 MB on the way in and again on the way out. Per the Node.js documentation, transferList sidesteps that for ArrayBuffer payloads by moving ownership instead of copying, which turns an expensive clone into a near-free pointer handoff. Worth reaching for any time the payload is measured in megabytes rather than kilobytes.
Why Build a Worker Pool Instead of Spawning Per Request?
Spawning a new Worker per request has real overhead, tens of milliseconds of startup cost per worker. Under load, that overhead compounds. A pool that reuses a fixed number of long-lived workers, dispatching tasks to whichever is free, avoids it:
import { Worker } from 'node:worker_threads';
import os from 'node:os';
import path from 'node:path';
class WorkerPool {
#workers: Worker[] = [];
#queue: Array<{ data: Buffer; resolve: (v: string) => void }> = [];
#freeWorkers: Worker[] = [];
constructor(workerFile: string, size = os.cpus().length) {
for (let i = 0; i < size; i++) {
const worker = new Worker(workerFile);
worker.on('message', result => {
const task = this.#queue.shift();
task?.resolve(result);
this.#freeWorkers.push(worker);
this.#runNext();
});
this.#workers.push(worker);
this.#freeWorkers.push(worker);
}
}
run(data: Buffer): Promise<string> {
return new Promise(resolve => {
this.#queue.push({ data, resolve });
this.#runNext();
});
}
#runNext() {
if (this.#queue.length === 0 || this.#freeWorkers.length === 0) {return;}
const worker = this.#freeWorkers.shift()!;
const task = this.#queue[0];
worker.postMessage(task.data);
}
}
const pool = new WorkerPool(path.resolve(import.meta.dirname, 'hash-worker.js'));
Sizing the pool to os.cpus().length means every core stays busy under sustained load without oversubscribing the CPU with more threads than there are cores to run them. Check what that call actually returns in your deployment before trusting it. Inside a container limited to 2 CPUs, os.cpus().length still reports the host's core count on most Node versions, so a pool sized from it on a 32-core host will happily spawn 32 threads to share 2 cores worth of quota. The result is worse throughput than a single thread, plus memory pressure from 32 V8 isolates, each of which carries its own heap. Read the cgroup limit or set the pool size from an environment variable instead.
Worker Threads vs Child Processes vs the libuv Thread Pool
| Approach | Shares memory | Startup cost | Best for |
|---|---|---|---|
| Worker threads | Yes (SharedArrayBuffer) | Moderate (tens of ms) | CPU-bound JS computation |
| Child processes | No, serialized IPC only | High | Running external programs, isolation for crash safety |
| libuv thread pool (built-in) | N/A, internal to Node | None, already running | File I/O, DNS lookups, some crypto (handled automatically) |
Node already uses a thread pool internally for things like fs operations and crypto.pbkdf2, you don't manage that pool yourself. Worker threads are for the CPU-bound work you write, not the I/O Node already parallelizes for you.
The 3 options split cleanly by what they isolate. The libuv thread pool, 4 threads by default and tunable through UV_THREADPOOL_SIZE, handles Node's own async file and crypto work with no API for you to touch. Worker threads run your JavaScript on separate threads inside the same process, each with its own V8 isolate and heap but a shared address space, so startup costs tens of milliseconds and message passing is cheap. Child processes give you a whole separate Node process, which costs a hundred milliseconds or more to start and communicates over IPC, but survives a crash in isolation and can run a different binary entirely. Pick workers for CPU-bound JavaScript, child processes for running something that isn't your app.
What Goes Wrong Once Workers Are in Production?
The pool works on your laptop. Then it meets real traffic, and three things tend to surface.
Unhandled worker errors take the process down. A worker that throws emits an error event on the parent's Worker object, and if nothing is listening, Node treats it as an uncaught exception. Attach worker.on('error', ...) and worker.on('exit', ...) when you create each worker, and have the exit handler replace the dead worker in the pool. Otherwise a single malformed payload retires one thread permanently, and the pool silently degrades until nothing is left to hand work to.
Memory doesn't come back the way you expect. Each worker gets its own V8 isolate and its own heap, so eight workers means roughly eight times the baseline heap before you've processed anything. On a 512MB container that's most of your budget spent at startup. Set resourceLimits: { maxOldGenerationSizeMb } per worker and size the pool against the container limit rather than against os.cpus().length, which reports the host's core count, not your cgroup quota. That mismatch is the single most common reason a pool that behaved in staging gets OOM-killed in production.
Structured clone isn't free either. Passing a large object through postMessage copies it, and for a big enough payload the copy costs more than the computation you moved off the main thread. If you're passing anything above a megabyte or so, transfer an ArrayBuffer instead so ownership moves without a copy.
Conclusion
Worker threads solve one specific problem: synchronous CPU-bound JavaScript blocking the single event loop thread. If your bottleneck is I/O, network calls, database queries, file reads, worker threads won't help, the event loop already handles those efficiently. Profile first, confirm the block is CPU-bound, then move that specific function into a pooled worker rather than spawning one per request.