Node.js Worker Threads: Parallel Processing with worker_threads
JavaScript is single-threaded by nature. The event loop handles one task at a time, which works beautifully for I/O-bound operations like network requests and file reading. But what happens when you need to perform CPU-intensive computations? Your application freezes, and users wait.
Worker Threads solve this problem. They let you run JavaScript in parallel, fully utilizing multi-core CPUs without blocking the main thread.
Why worker threads matter
Node.js excels at handling concurrent I/O operations, but CPU-bound tasks block the event loop. Consider these scenarios:
- Image or video processing
- Cryptographic operations
- Data compression
- Machine learning inference
- Complex mathematical calculations
Without Worker Threads, these operations monopolize the event loop, making your application unresponsive.
Worker Threads provide true parallelism by running JavaScript in separate threads that share minimal data. Each worker has its own V8 instance, event loop, and memory, but can communicate with the main thread through message passing.
Creating your first worker thread
The worker_threads module ships with Node.js, no dependencies needed.
Basic Example
Create a file named worker.js:
// worker.js
const { workerData, parentPort } = require('worker_threads');
// Perform CPU-intensive work
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.number);
// Send result back to main thread
parentPort.postMessage(result);
The worker runs fibonacci() with the input from workerData and sends the result back to the main thread via parentPort.postMessage(). Notice that each worker gets its own V8 instance, so the recursive call stack is completely isolated from the main event loop. Now create the main script that instantiates and listens to this worker:
// main.js
const { Worker } = require('worker_threads');
const path = require('path');
const worker = new Worker(path.join(__dirname, 'worker.js'), {
workerData: { number: 40 }
});
worker.on('message', (result) => {
console.log(`Fibonacci(40) = ${result}`);
});
worker.on('error', (err) => {
console.error('Worker error:', err);
});
worker.on('exit', (code) => {
if (code !== 0) {
console.error(`Worker stopped with exit code ${code}`);
}
});
The main thread creates a Worker, passes configuration through workerData, and sets up listeners for the three lifecycle events: message, error, and exit. When you run it, the result appears after the CPU-bound computation completes without blocking anything else:
node main.js
# Fibonacci(40) = 102334155
The main thread stays responsive while the worker computes Fibonacci(40) in the background. This is the core pattern: create a worker, give it data, and listen for the result. All inter-thread communication goes through message passing.
Passing data between threads
Workers communicate via parentPort.postMessage() and worker.postMessage(). This uses the Structured Clone algorithm, supporting most JavaScript types including:
- Primitives (strings, numbers, booleans)
- Objects and arrays
- TypedArrays and Buffers
- Error objects
Workers can also receive messages from the main thread, which enables bidirectional workflows. The worker listens on parentPort for incoming messages and can post results back at any time:
// main.js
const { Worker } = require('worker_threads');
const worker = new Worker(`
const { parentPort, workerData } = require('worker_threads');
parentPort.on('message', (msg) => {
const result = msg.data * 2;
parentPort.postMessage({ result });
});
`);
worker.postMessage({ data: 21 });
worker.on('message', (msg) => {
console.log('Received:', msg.result); // 42
});
The bidirectional pattern lets the main thread send tasks and the worker respond with results as they complete. The inline worker script here doubles whatever number it receives, but the same pattern works for any request-response workflow between threads.
Transferable objects for performance
When passing large data like TypedArrays, use transferable objects to avoid copying:
// main.js
const { Worker } = require('worker_threads');
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const int32Array = new Int32Array(buffer);
const worker = new Worker(`
const { parentPort, workerData } = require('worker_threads');
// workerData.buffer is now the buffer (not copied)
const array = new Int32Array(workerData.buffer);
console.log('First value:', array[0]);
parentPort.postMessage('done');
`);
// Transfer ownership (buffer becomes unusable in main thread)
worker.postMessage({ buffer }, [buffer]);
After transferring, the main thread can no longer access the buffer. This is critical for performance with large datasets because it avoids the cost of copying megabytes of memory across the thread boundary. For workloads that process the same buffer repeatedly across many workers, the savings add up quickly.
Handling multiple workers
For parallel task processing, create a pool of workers to distribute CPU-bound work across all available cores:
// worker-pool.js
const { Worker } = require('worker_threads');
const os = require('os');
class WorkerPool {
constructor(workerPath, poolSize = os.cpus().length) {
this.workerPath = workerPath;
this.poolSize = poolSize;
this.workers = [];
this.queue = [];
this.init();
}
init() {
for (let i = 0; i < this.poolSize; i++) {
this.workers.push(this.createWorker());
}
}
createWorker() {
const worker = new Worker(this.workerPath);
worker.isAvailable = true;
return worker;
}
}
module.exports = WorkerPool;
The pool pre-creates one worker per CPU core and tracks availability with a simple boolean flag. When a worker finishes a task, it becomes available again and pulls the next task from the queue. This keeps all cores busy without creating and destroying threads for each request. The runTask method wraps the worker protocol in a Promise so callers can use await or Promise.all:
runTask(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
const availableWorker = this.workers.find(w => w.isAvailable);
if (availableWorker) {
this.executeTask(availableWorker, task);
} else {
this.queue.push(task);
}
});
}
executeTask(worker, task) {
worker.isAvailable = false;
const handler = (result) => {
worker.removeListener('message', handler);
worker.isAvailable = true;
task.resolve(result);
// Process queued task
if (this.queue.length > 0) {
const nextTask = this.queue.shift();
this.executeTask(worker, nextTask);
}
};
worker.on('message', handler);
worker.postMessage(task.data);
}
Using the pool from the main thread is straightforward. Create one instance, map your tasks through runTask(), and collect results with Promise.all. Each task returns a Promise, so you can await individual results or gather them all at once. The pool reuses workers across tasks, so setup cost is paid only once:
const WorkerPool = require('./worker-pool');
const pool = new WorkerPool('./compute-worker.js');
const tasks = [40, 39, 38, 37, 36, 35, 34, 33];
const promises = tasks.map(n => pool.runTask({ number: n }));
Promise.all(promises).then(results => {
console.log('All results:', results);
});
The pool spreads eight Fibonacci computations across the available cores, returning results in order. For data that changes while workers run, message passing works well, but for truly shared state you need a different primitive.
SharedArrayBuffer for shared memory
For truly efficient data sharing, use SharedArrayBuffer:
// main.js
const { Worker } = require('worker_threads');
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
sharedArray[0] = 42;
const worker = new Worker(`
const { parentPort, workerData } = require('worker_threads');
// Access shared memory directly
const array = new Int32Array(workerData.buffer);
console.log('Read from shared memory:', array[0]);
array[0] = 100; // Modify shared data
parentPort.postMessage('modified');
`);
worker.postMessage({ buffer: sharedBuffer });
worker.on('message', () => {
console.log('Main thread sees:', sharedArray[0]); // 100
});
Note: SharedArrayBuffer requires specific HTTP headers (
Cross-Origin-Opener-PolicyandCross-Origin-Embedder-Policy) when used in browsers. In Node.js, it works out of the box. Shared memory is faster than message passing for frequent reads and writes, but it comes with the usual concurrency risks: data races, torn writes, and ordering issues. When you only need to pass data once or twice, stick withpostMessage.
Error handling and lifecycle
Proper error handling is essential:
const { Worker } = require('worker_threads');
const worker = new Worker('./task-worker.js');
worker.on('error', (err) => {
console.error('Uncaught error in worker:', err);
});
worker.on('exit', (code) => {
if (code !== 0) {
console.error(`Worker exited with code ${code}`);
// Optionally restart the worker
}
});
// Handle uncaught exceptions in the worker
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection in worker:', reason);
});
When to use worker threads
Use Worker Threads when:
- CPU-intensive calculations block the event loop
- You need to utilize multiple CPU cores
- Background data processing is required
- Image, audio, or video processing
Consider alternatives for other scenarios:
| Scenario | Solution |
|---|---|
| I/O-bound tasks | Async/await, Promises |
| Simple parallelism | child_process module |
| Microservices | Separate processes or containers |
| GPU compute | WebGPU or GPU.js |
Choosing the right workload
Worker Threads are the right fit when the work is CPU-heavy enough to block the event loop for a noticeable amount of time. That usually means repeated calculations, large transformations, or data processing that would make a request feel sluggish if it ran on the main thread. If the task is mostly waiting on disk or network, a worker adds more complexity than value because Node.js already handles I/O concurrency well.
Think about workers as a tool for isolation as much as parallelism. A worker has its own memory and event loop, which means it can chew through expensive work without freezing the rest of the app. That separation is useful when the main process must stay responsive, and it makes boundaries clearer because the main thread focuses on coordination while the worker handles the expensive part.
Message flow and ownership
The best worker code keeps the message protocol simple. The main thread should know what it is sending, what shape of result to expect, and what to do when a task fails. A worker that receives a clear input and returns a clear output stays easy to maintain and easy to test because it behaves more like a small service than a hidden helper.
Ownership matters too. Decide which thread creates the worker, handles its lifecycle, and decides when to shut it down. When that responsibility is spread around, cleanup gets messy and duplicate workers become more likely. A small wrapper around creation and task dispatch often makes the whole arrangement easier to reason about because each thread has a well-defined role.
When not to use workers
Workers are not the answer for every slow task. If the work is short, rare, or mostly waiting on external systems, the extra setup is probably not worth it. Spinning up a worker, moving data across the boundary, and collecting the result has real overhead. That overhead is fine when the job is big enough, but it can be counterproductive when the task is tiny.
Data size matters too. Large objects can cost time to clone or transfer, so the boundary should be worth the trip. When the task is truly CPU-bound, the tradeoff is usually good. When it is not, a simpler pattern often wins. That judgment call is part of using workers well.
Final worker note
Workers are most useful when they let the main thread stay calm. If the app can answer requests, update the UI, or keep moving while the worker does the hard part, the extra setup is usually worth it. A small worker boundary also gives you a cleaner place to measure cost: if the overhead of moving data is bigger than the work itself, the main thread may be the better home for that task. That simple check helps you avoid parallelism that only looks useful on paper.
Next steps
Worker Threads help Node.js reach its potential for CPU-intensive workloads. Key takeaways:
- Use
worker_threadsmodule for CPU-bound tasks - Communicate via message passing for simplicity
- Use transferable objects for large data
- Implement worker pools for efficient task distribution
- Consider
SharedArrayBufferfor real-time data sharing
With Worker Threads, your Node.js applications can handle computationally intensive workloads without sacrificing the developer experience that makes Node.js great.