Node.js 64 🟢 Parallel Processing and Multithreading with Worker Threads (node:worker_threads)
Node.js runs your JavaScript on a single main thread. This is by design—the event loop handles thousands of concurrent I/O operations without the overhead of thread management. But there is a category of work the event loop cannot save you from: CPU-bound computation. A tight loop calculating a cryptographic hash, parsing a massive JSON payload, or resizing an image blocks the event loop entirely. While that code runs, no other request is served. The server appears frozen.
Worker threads are Node.js’s answer to this problem. They are real operating-system threads that run JavaScript in parallel with the main thread, each with its own V8 isolate and its own event loop . They are not a replacement for async I/O—they are an escape hatch for the work that async I/O cannot handle.
This chapter covers the worker_threads module: how to spawn workers, how to communicate with them, how to avoid the hidden cost of data copying, and when not to use them at all.
Key point: Worker threads run JavaScript in parallel on separate OS threads, each with its own V8 isolate; data is copied between threads by default using the structured clone algorithm, and SharedArrayBuffer is the mechanism for true shared memory when copying is too expensive.
Why worker threads exist
The blocked event loop problem. Node.js is single-threaded because I/O is handled asynchronously by the operating system. A database query does not block the event loop—it registers a callback and moves on. But a for loop from 0 to 10 billion does block it. No callback can run until that loop finishes. Worker threads solve this by moving CPU-bound work off the main thread entirely .
The process-vs-thread distinction. Node.js has two parallelization mechanisms: child_process and worker_threads. Child processes are separate OS processes with their own memory space and their own V8 instance. Worker threads are separate threads within the same process, sharing the same memory space at the OS level. Threads are lighter to create, cheaper to communicate between, and better suited for workloads that need to pass data back and forth .
Copy-by-default safety. When you send data to a worker via postMessage, the data is copied using the structured clone algorithm . The worker gets its own deep copy. The main thread keeps its original. This means two threads cannot accidentally mutate the same object—the class of bugs that plagues shared-memory concurrency in C and Java is largely absent by default .
The cost of copying. Copy-by-default is safe, but it is not free. A 10 MB object sent to a worker is serialized, transferred, and deserialized—twice if the result comes back . For large payloads, the copying cost can exceed the computation cost. This is why SharedArrayBuffer and transferList exist: they let you avoid copying when the data is large enough that the overhead matters.
The isolation boundary. Workers do not share closures, module state, or global variables with the main thread. They load their own modules, maintain their own globals, and cannot access the main thread’s variables . This isolation is a feature—it prevents accidental coupling—but it also means you cannot simply pass a database connection or a class instance to a worker and expect it to work.
a. Spawning a worker and basic communication
The worker_threads module provides the Worker class. Creating a worker runs a JavaScript file (or a string of code) in a new thread. The parent and worker communicate through MessagePort objects: parentPort in the worker, and the Worker instance in the parent.
// main.js
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (msg) => {
console.log('Parent received:', msg);
});
worker.postMessage('Hello from parent');
} else {
parentPort.on('message', (msg) => {
console.log('Worker received:', msg);
parentPort.postMessage('Hello from worker');
});
}
The isMainThread boolean distinguishes the two execution contexts. When the file is run directly, isMainThread is true. When the same file is loaded inside a Worker, isMainThread is false, and parentPort is available .
You can also spawn a worker from a separate file:
// main.js
const { Worker } = require('node:worker_threads');
const worker = new Worker('./worker.js');
// worker.js
const { parentPort } = require('node:worker_threads');
parentPort.on('message', (msg) => {
parentPort.postMessage(`Echo: ${msg}`);
});
The Worker constructor accepts options. workerData is a value that is cloned and made available in the worker as require('node:worker_threads').workerData . resourceLimits caps the worker’s heap size—if the worker exceeds maxOldGenerationSizeMb or maxYoungGenerationSizeMb, it is terminated . This is useful for preventing a runaway computation from consuming all available memory.
b. The structured clone algorithm and transferList
By default, postMessage uses the structured clone algorithm to copy the data . This algorithm recursively traverses the object, copying primitives, plain objects, arrays, Date, RegExp, Map, Set, ArrayBuffer, and typed arrays. It does not copy functions, class instances (methods are functions), symbols, DOM nodes, or promises . Attempting to send any of these throws a DataCloneError.
The copy semantics mean the worker’s copy is independent. Mutations in the worker do not affect the parent’s original, and vice versa. This is safe but potentially expensive.
For ArrayBuffer objects, you can avoid the copy entirely by using the transferList option. Transferring an ArrayBuffer moves ownership to the worker, and the parent’s reference becomes unusable .
const buffer = new ArrayBuffer(1024);
worker.postMessage({ buffer }, [buffer]);
// buffer is now unusable in the parent
Transfer is a move, not a copy. It is faster for large buffers, but it is not always appropriate—if the parent needs to retain the data, transfer is not an option.
For shared memory, SharedArrayBuffer allows multiple threads to read and write the same underlying memory. Changes made by one thread are visible to others . However, SharedArrayBuffer only shares binary data—you cannot share plain JavaScript objects. And once you introduce shared memory, you reintroduce the classic concurrency problems: data races, torn reads, and the need for Atomics to coordinate access .
c. Worker pools and lifecycle management
Creating a worker is not free. Each worker spins up a new V8 isolate, which costs memory and startup time . Creating a worker per request is an anti-pattern—the overhead of spawning exceeds the benefit of parallelizing the work .
The correct pattern is a worker pool: a fixed number of workers that stay alive, receiving tasks from a queue and returning results. The pool size is typically the number of CPU cores, since CPU-bound work cannot benefit from more threads than cores .
const os = require('node:os');
const numCPUs = os.cpus().length;
Node.js does not include a built-in worker pool. Libraries like piscina manage pools, or you can build a simple one by keeping workers alive and sending tasks via postMessage.
Workers must be terminated explicitly. A worker keeps running until it finishes its script or is terminated with worker.terminate() . If you forget to clean up workers, they consume memory and CPU indefinitely. A timeout on idle workers or a pool with a shutdown routine is standard practice .
Errors in workers do not propagate to the parent’s try/catch. They are emitted as 'error' events on the Worker instance . If you do not listen for 'error', the worker fails silently—or worse, the process exits. Always attach an error handler .
Complete Example Session
The following session builds a worker that performs a CPU-intensive computation, sends the result back, and is managed by a simple pool.
// ============================================
// PART 1: THE WORKER FILE (worker.js)
// ============================================
const { parentPort, workerData } = require('node:worker_threads');
function fibonacci(n) {
if (n < 2) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
parentPort.on('message', (task) => {
if (task.type === 'compute') {
const result = fibonacci(task.n);
parentPort.postMessage({ id: task.id, result });
}
});
// ============================================
// PART 2: THE MAIN FILE (main.js)
// ============================================
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
const os = require('node:os');
if (isMainThread) {
const POOL_SIZE = Math.min(os.cpus().length, 4);
const workers = [];
const taskQueue = [];
let nextId = 1;
// ============================================
// PART 3: CREATE THE WORKER POOL
// ============================================
for (let i = 0; i < POOL_SIZE; i++) {
const worker = new Worker('./worker.js');
worker.busy = false;
worker.on('message', (result) => {
worker.busy = false;
console.log(`Task ${result.id} result: ${result.result}`);
dispatchNext();
});
worker.on('error', (err) => {
console.error(`Worker error: ${err.message}`);
});
worker.on('exit', (code) => {
if (code !== 0) console.error(`Worker exited with code ${code}`);
});
workers.push(worker);
}
// ============================================
// PART 4: TASK DISPATCH
// ============================================
function dispatchNext() {
const idle = workers.find((w) => !w.busy);
const task = taskQueue.shift();
if (idle && task) {
idle.busy = true;
idle.postMessage(task);
}
}
// ============================================
// PART 5: SUBMIT TASKS
// ============================================
function submit(n) {
return new Promise((resolve) => {
taskQueue.push({ id: nextId++, type: 'compute', n });
dispatchNext();
});
}
// ============================================
// PART 6: RUN TASKS
// ============================================
async function main() {
const results = await Promise.all([
submit(40),
submit(35),
submit(38),
submit(32),
]);
console.log('All done');
}
main().catch(console.error);
// ============================================
// PART 7: GRACEFUL SHUTDOWN
// ============================================
process.on('SIGTERM', async () => {
await Promise.all(workers.map((w) => w.terminate()));
process.exit(0);
});
// ============================================
// PART 8: TRANSFERRING A LARGE BUFFER
// ============================================
const bigBuffer = new ArrayBuffer(10 * 1024 * 1024);
const worker = workers[0];
worker.postMessage({ buffer: bigBuffer }, [bigBuffer]);
// bigBuffer is now unusable in the main thread
// ============================================
// PART 9: SHARED MEMORY (when copying is too slow)
// ============================================
const { SharedArrayBuffer } = require('node:worker_threads');
const sab = new SharedArrayBuffer(4);
const int32 = new Int32Array(sab);
workers[0].postMessage({ sab });
// Worker can modify int32 directly
// ============================================
// PART 10: ENVIRONMENT DATA
// ============================================
const { setEnvironmentData } = require('node:worker_threads');
setEnvironmentData('config', { maxRetries: 3 });
// New workers can read this with getEnvironmentData
}
The ten parts cover the worker file, the main file with pool creation, task dispatch, submission, execution, graceful shutdown, buffer transfer, shared memory, and environment data. What the session demonstrates is that workers are not fire-and-forget: they require lifecycle management, error handling, and a strategy for data movement that avoids copying when it matters.
Quick Reference
Core API
| Item | Purpose |
|---|---|
new Worker(path) | Spawn a worker |
worker.postMessage(value) | Send to worker |
parentPort.postMessage(value) | Send to parent |
worker.on('message', fn) | Receive from worker |
parentPort.on('message', fn) | Receive from parent |
worker.terminate() | Stop worker |
isMainThread | true in main thread |
Worker Constructor Options
| Option | Meaning |
|---|---|
workerData | Cloned data available in worker |
resourceLimits | Heap and stack limits |
env | Environment variables (SHARE_ENV to share) |
stdout / stderr | Redirect output |
Structured Clone Support
| Type | Cloned? |
|---|---|
| Primitives, plain objects, arrays | ✅ |
| Date, RegExp, Map, Set | ✅ |
| ArrayBuffer, TypedArrays | ✅ |
| Functions, class instances | ❌ |
| Symbols, Promises, DOM nodes | ❌ |
Data Transfer Strategies
| Strategy | Cost | Use When |
|---|---|---|
| Structured clone | Copy | Small data, safety |
transferList | Move | Large ArrayBuffer |
SharedArrayBuffer | None (shared) | Multi-worker shared state |
Best Practices
✅ Do This:
worker.on('error', (err) => log(err)); // ✅ handle errors
const pool = new WorkerPool(os.cpus().length); // ✅ pool, not per-request
worker.postMessage(data, [buffer]); // ✅ transfer large buffers
await worker.terminate(); // ✅ clean up
❌ Don’t Do This:
new Worker('./worker.js'); // ❌ per-request spawn
worker.postMessage(classInstance); // ❌ DataCloneError
// no error handler // ❌ silent failures
// workers left running // ❌ memory leak
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
DataCloneError | Sending functions or class instances | Send plain data, reconstruct in worker |
| Worker silent failure | No 'error' listener | Always attach error handler |
| Slow despite workers | Copy overhead exceeds computation | Use transfer or shared memory |
| Memory exhaustion | Too many workers | Use bounded pool |
| Worker cannot access main state | Isolated V8 isolate | Pass data or reconfigure in worker |
Real-World Examples
1. Image resizing
const worker = new Worker('./image-worker.js');
worker.postMessage({ imagePath, width: 800 });
2. JSON parsing
parentPort.on('message', (json) => {
parentPort.postMessage(JSON.parse(json));
});
3. Cryptographic hashing
parentPort.on('message', (data) => {
const hash = crypto.createHash('sha256').update(data).digest('hex');
parentPort.postMessage(hash);
});
4. Transfer buffer
worker.postMessage({ buffer }, [buffer]);
5. Shared counter
const sab = new SharedArrayBuffer(4);
const counter = new Int32Array(sab);
Atomics.add(counter, 0, 1);
6. Worker pool size
const poolSize = require('node:os').cpus().length;
7. Resource limits
new Worker('./worker.js', {
resourceLimits: { maxOldGenerationSizeMb: 256 }
});
8. Graceful shutdown
process.on('SIGTERM', async () => {
await Promise.all(workers.map(w => w.terminate()));
});
9. Environment data
setEnvironmentData('config', { debug: true });
10. Check if main thread
if (isMainThread) { /* spawn workers */ } else { /* worker code */ }
Visual
Worker Thread Architecture
┌──────────────────────────────────────────────┐
│ MAIN THREAD │
│ Event Loop │
│ ┌────────────────────────────────────────┐ │
│ │ Handles I/O, responds to requests │ │
│ └────────────────┬───────────────────────┘ │
│ │ │
│ postMessage (structured clone) │
│ │ │
│ ┌─────────┴─────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ WORKER 1 │ │ WORKER 2 │ │
│ │ V8 isolate │ │ V8 isolate │ │
│ │ CPU work │ │ CPU work │ │
│ └─────────────┘ └─────────────┘ │
│ Same process, different threads │
└──────────────────────────────────────────────┘
Copy vs Transfer vs Shared
┌──────────────────────────────────────────────┐
│ STRUCTURED CLONE (default) │
│ Parent ──▶ copy ──▶ Worker │
│ Both have independent copies │
│ Cost: O(size) │
│ │
│ TRANSFER (transferList) │
│ Parent ──▶ move ──▶ Worker │
│ Parent's reference becomes unusable │
│ Cost: O(1) │
│ │
│ SHARED MEMORY (SharedArrayBuffer) │
│ Parent ◀──▶ same memory ◀──▶ Worker │
│ Both read/write simultaneously │
│ Cost: 0, but requires Atomics │
└──────────────────────────────────────────────┘
Worker Pool
┌──────────────────────────────────────────────┐
│ TASK QUEUE │
│ [T1] [T2] [T3] [T4] [T5] │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ POOL (size = CPU cores) │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │ W1 │ │ W2 │ │ W3 │ │ W4 │ │ │
│ │ │busy │ │idle │ │busy │ │idle │ │ │
│ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │
│ └────────────────────────────────────────┘ │
│ Idle workers pick up tasks from queue │
└──────────────────────────────────────────────┘
When to Use Worker Threads
┌──────────────────────────────────────────────┐
│ CPU-BOUND (long loop, parsing, hashing) │
│ ✅ USE worker_threads │
│ │
│ I/O-BOUND (DB, HTTP, file read) │
│ ❌ DON'T USE — event loop handles it │
│ │
│ UNTRUSTED CODE │
│ ❌ DON'T USE — worker is not a sandbox │
│ Use child process with dropped privileges │
│ │
│ SHORT TASK (< 10ms) │
│ ❌ DON'T USE — overhead exceeds benefit │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Module | node:worker_threads |
| Thread model | OS threads, same process |
| Default data transfer | Structured clone (copy) |
| Transfer mechanism | transferList (move) |
| Shared memory | SharedArrayBuffer + Atomics |
| Worker creation cost | V8 isolate per worker |
| Recommended pool size | CPU cores |
| Error handling | 'error' event |
| Cleanup | worker.terminate() |
| Not for | I/O-bound work |
Key takeaways:
- Worker threads are for CPU-bound work. They move computation off the main thread so the event loop stays responsive .
- Data is copied by default. The structured clone algorithm copies the entire payload, which can be expensive for large objects .
transferListmoves instead of copies. For largeArrayBufferobjects, transfer avoids the serialization cost .SharedArrayBuffershares memory directly. Changes in one thread are visible in others, but you needAtomicsto coordinate access .- Workers are isolated. They do not share closures, module state, or globals with the main thread. You must pass data explicitly .
- Use a pool, not per-request workers. Creating a worker is expensive; a fixed pool amortizes the cost .
- Handle errors explicitly. Worker errors do not propagate to the main thread’s
try/catch—they emit'error'events . - Workers are not a sandbox. They share the same process and privileges as the main thread. For untrusted code, use a separate process with dropped privileges .
Remember: Worker threads are Node.js’s mechanism for true parallel JavaScript execution. They exist because the event loop cannot handle CPU-bound work without blocking. The default copy semantics keep you safe from data races, but the copy cost can be significant. For large data, use transferList or SharedArrayBuffer. For repeated tasks, use a pool. For I/O-bound work, do not use them at all—the event loop already handles it. Worker threads are a tool for a specific problem: CPU-bound computation that would otherwise freeze your server.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!