worker_threads
worker_threads offload CPU-heavy JavaScript to parallel threads without spawning a new OS process - use workerData, message passing, and optionally SharedArrayBuffer for shared state.
Recipe
import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
const worker = new Worker(fileURLToPath(new URL('./hash-worker.js', import.meta.url)), {
workerData: { input: 'payload' },
});
worker.on('message', (result) => console.log(result));When to reach for this:
- bcrypt/scrypt rounds, image resize, PDF generation in JS
- Large JSON transform or CSV parse blocking event loop
- Parallel map over CPU work items with fixed pool size
- Crypto operations not delegating to
cryptothread pool sufficiently
Working Example
// main.ts
import { Worker } from 'node:worker_threads';
import { cpus } from 'node:os';
import { fileURLToPath } from 'node:url';
const poolSize = Math.min(4, cpus().length);
const workers: Worker[] = [];
const queue: Array<{ data: number[]; resolve: (v: number) => void }> = [];
for (let i = 0; i < poolSize; i++) {
const w = new Worker(fileURLToPath(new URL('./sum-worker.js', import.meta.url)));
w.on('message', (sum: number) => {
const job = queue.shift();
job?.resolve(sum);
w.postMessage(job?.data ?? []);
});
workers.push(w);
}
export function sumInWorker(nums: number[]): Promise<number> {
return new Promise((resolve) => {
queue.push({ data: nums, resolve });
const idle = workers.find((w) => !w.threadId) ?? workers[0];
idle.postMessage(nums);
});
}// sum-worker.js
import { parentPort, workerData } from 'node:worker_threads';
parentPort?.on('message', (nums: number[]) => {
const sum = nums.reduce((a, b) => a + b, 0);
parentPort?.postMessage(sum);
});
if (workerData) {
parentPort?.postMessage('ready');
}What this demonstrates:
- Pool size capped - do not spawn unbounded workers per request
postMessageuses structured clone algorithm - not for huge graphs every tick- Separate worker entry file - no
eval: truein production - Queue pairs jobs with workers - production pools use mature libraries or robust scheduling
Deep Dive
How It Works
- Each Worker is a thread with isolated JS heap except shared memory explicitly shared.
workerDatacloned once at construction.parentPort.postMessageasync between threads - serialized copies.SharedArrayBuffer+ Atomics for lock-free counters - advanced, careful with races.
worker_threads vs child_process
| Factor | worker_threads | child_process |
|---|---|---|
| Startup | Faster | Slower |
| Isolation | Shared process user | Separate address space |
| Best for | CPU JS | CLI binaries |
| Crash impact | Can tear down process | Isolated child |
TypeScript Notes
import { isMainThread, parentPort, workerData } from 'node:worker_threads';
if (!isMainThread) {
const { job } = workerData as { job: string };
parentPort?.postMessage({ ok: true, job });
}Gotchas
- Spawning worker per HTTP request - thread explosion. Fix: fixed pool reused across requests.
- Posting huge objects frequently - clone cost dominates. Fix: SharedArrayBuffer or transfer ArrayBuffers with transfer list.
- Shared mutable state without Atomics - data races. Fix: message passing default; SAB only when measured need.
- Native addons not thread-safe - undefined behavior. Fix: run addon in main thread or documented thread-safe mode.
- Unhandled errors in worker - emit
errorevent - attach handler orworker.on('error')crashes supervision.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| child_process | Run non-JS binary | Pure JS CPU |
| cluster | Multi-core HTTP accept | Single heavy compute job |
| WASM + worker | Sandboxed compute | Simple JS loop |
| External job service | Burst CPU fleet | Low latency in-request |
FAQs
How many workers?
Often cpus().length - 1 or fixed 4 - profile; more than cores can hurt.
Can workers share DB pool?
No - each worker needs own connections or message main thread for I/O.
What is transferable?
ArrayBuffer can transfer ownership zero-copy via second argument to postMessage.
worker_threads and ESM?
Worker entry can be .js ESM - use import.meta.url to resolve path from main.
Does NestJS use workers?
Not by default - offload manually or use BullMQ for background CPU.
Debugging workers?
node --inspect with --inspect-port per worker - or log from worker to main.
CPU task still blocks worker?
Yes - one long task blocks that worker, not main thread - pool size matters.
Alternative pool libraries?
piscina - popular worker pool abstraction on npm.
Event loop on main still matters?
Yes - workers help CPU; main thread still handles all HTTP I/O.
SharedArrayBuffer security?
Requires cross-origin isolation in browsers; Node has fewer restrictions but races remain.
Terminate stuck worker?
await worker.terminate() - forceful; prefer cooperative cancel messages.
Relation to UV_THREADPOOL?
libuv pool handles some I/O/crypto - workers for JS CPU loops libuv cannot fix.
Related
- Detecting Event-Loop Blockage - when to offload
- child_process - OS processes
- Processes Basics - decision overview
- Event Loop Best Practices - main thread rules
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.