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.
TL;DR: 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. Reuse a pool of workers sized to your CPU core count instead of spawning one per task.
Proving 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.
Moving 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.
Building 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.
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.
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.