Worker Threads in Node.js
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);
Now create the main script:
// 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}`);
}
});
Run it:
node main.js
# Fibonacci(40) = 102334155
The main thread stays responsive while the worker computes Fibonacci(40) in the background.
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
Bidirectional Communication
// 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
});
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]);
This is crucial for performance when working with large datasets.
Handling Multiple Workers
For parallel task processing, create a pool of workers:
// 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;
}
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);
}
}
module.exports = WorkerPool;
Usage:
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);
});
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.
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 |
Conclusion
Worker Threads unlock Node.js’s potential for CPU-intensive workloads. By offloading heavy computations to separate threads, you keep the main event loop responsive while utilizing all available CPU cores.
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.