Worker Threads in Node.js
Learn how to use Worker Threads for CPU-intensive tasks in Node.js. Covers thread creation, message passing, SharedArrayBuffer, and thread pools.
What you'll learn
- ✓Why Node.js needs worker threads
- ✓How to create workers and communicate via messages
- ✓Sharing memory with SharedArrayBuffer
- ✓Building a thread pool for CPU-bound work
- ✓When to use workers vs child processes vs clustering
Prerequisites
- •Node.js event loop basics
- •Understanding of single-threaded JavaScript
- •Basic knowledge of concurrency concepts
Node.js runs JavaScript on a single thread. This is great for IO-bound work but terrible for CPU-intensive tasks like image processing, cryptography, or data parsing. A single heavy computation blocks the event loop and freezes your entire server. Worker threads solve this by letting you run JavaScript in parallel threads that share memory with the main thread.
The problem
const http = require('http');
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
http.createServer((req, res) => {
// This blocks the event loop for seconds
const result = fibonacci(45);
res.end(`Result: ${result}`);
}).listen(3000);
While computing fibonacci(45), the server cannot handle any other requests. Every user waits.
Creating a worker
main.js:
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js', {
workerData: { n: 45 }
});
worker.on('message', (result) => {
console.log(`Result: ${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}`);
}
});
worker.js:
const { parentPort, workerData } = require('worker_threads');
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.n);
parentPort.postMessage(result);
The main thread continues handling other work while the worker computes fibonacci in a separate thread.
Inline workers
You can define the worker code inline using a data URL or by checking isMainThread:
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
// Main thread
const worker = new Worker(__filename, {
workerData: { n: 40 }
});
worker.on('message', (result) => {
console.log(`Fibonacci result: ${result}`);
});
} else {
// Worker thread
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.n);
parentPort.postMessage(result);
}
Communication channels
Basic message passing
Messages are serialized using the structured clone algorithm (similar to JSON.parse(JSON.stringify()) but supports more types including Map, Set, Date, RegExp, and ArrayBuffer).
// main.js
const worker = new Worker('./worker.js');
// Send a message to the worker
worker.postMessage({ type: 'process', data: [1, 2, 3, 4, 5] });
// Receive messages from the worker
worker.on('message', (msg) => {
if (msg.type === 'result') {
console.log('Processed:', msg.data);
} else if (msg.type === 'progress') {
console.log(`Progress: ${msg.percent}%`);
}
});
// worker.js
const { parentPort } = require('worker_threads');
parentPort.on('message', (msg) => {
if (msg.type === 'process') {
const data = msg.data;
const results = [];
for (let i = 0; i < data.length; i++) {
results.push(data[i] * 2);
parentPort.postMessage({
type: 'progress',
percent: Math.round(((i + 1) / data.length) * 100)
});
}
parentPort.postMessage({ type: 'result', data: results });
}
});
MessageChannel for direct worker-to-worker communication
const { Worker, MessageChannel } = require('worker_threads');
const { port1, port2 } = new MessageChannel();
const workerA = new Worker('./workerA.js', {
workerData: { port: port1 },
transferList: [port1]
});
const workerB = new Worker('./workerB.js', {
workerData: { port: port2 },
transferList: [port2]
});
Shared memory with SharedArrayBuffer
For high-performance scenarios, avoid message passing overhead by sharing memory directly:
// main.js
const { Worker } = require('worker_threads');
// Create shared memory (1024 integers)
const sharedBuffer = new SharedArrayBuffer(1024 * Int32Array.BYTES_PER_ELEMENT);
const sharedArray = new Int32Array(sharedBuffer);
// Initialize with data
for (let i = 0; i < 1024; i++) {
sharedArray[i] = i;
}
const worker = new Worker('./worker.js', {
workerData: { buffer: sharedBuffer }
});
worker.on('message', () => {
// Worker modified the shared array in-place
console.log('First 10 values:', Array.from(sharedArray.slice(0, 10)));
});
// worker.js
const { parentPort, workerData } = require('worker_threads');
const sharedArray = new Int32Array(workerData.buffer);
// Modify in-place (no copying)
for (let i = 0; i < sharedArray.length; i++) {
sharedArray[i] = sharedArray[i] * 2;
}
parentPort.postMessage('done');
Atomics for thread safety
When multiple threads access shared memory, use Atomics to prevent race conditions:
const { Worker } = require('worker_threads');
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const counter = new Int32Array(sharedBuffer);
const workers = [];
for (let i = 0; i < 4; i++) {
const worker = new Worker(`
const { parentPort, workerData } = require('worker_threads');
const counter = new Int32Array(workerData.buffer);
for (let i = 0; i < 10000; i++) {
Atomics.add(counter, 0, 1); // Thread-safe increment
}
parentPort.postMessage('done');
`, { eval: true, workerData: { buffer: sharedBuffer } });
workers.push(new Promise(resolve => worker.on('message', resolve)));
}
Promise.all(workers).then(() => {
console.log('Counter:', counter[0]); // Always 40000
});
Building a thread pool
Creating a new worker for each task is expensive. A thread pool reuses workers:
const { Worker } = require('worker_threads');
const os = require('os');
class ThreadPool {
constructor(workerScript, poolSize = os.cpus().length) {
this.workerScript = workerScript;
this.poolSize = poolSize;
this.workers = [];
this.queue = [];
this.activeWorkers = 0;
for (let i = 0; i < poolSize; i++) {
this.addWorker();
}
}
addWorker() {
const worker = new Worker(this.workerScript);
worker.busy = false;
worker.on('message', (result) => {
worker.busy = false;
worker.currentResolve(result);
this.activeWorkers--;
this.processQueue();
});
worker.on('error', (err) => {
worker.busy = false;
worker.currentReject(err);
this.activeWorkers--;
this.processQueue();
});
this.workers.push(worker);
}
run(data) {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this.processQueue();
});
}
processQueue() {
if (this.queue.length === 0) return;
const availableWorker = this.workers.find(w => !w.busy);
if (!availableWorker) return;
const { data, resolve, reject } = this.queue.shift();
availableWorker.busy = true;
availableWorker.currentResolve = resolve;
availableWorker.currentReject = reject;
this.activeWorkers++;
availableWorker.postMessage(data);
}
destroy() {
for (const worker of this.workers) {
worker.terminate();
}
}
}
// Usage
const pool = new ThreadPool('./compute-worker.js', 4);
async function main() {
const results = await Promise.all([
pool.run({ n: 40 }),
pool.run({ n: 41 }),
pool.run({ n: 42 }),
pool.run({ n: 43 }),
]);
console.log(results);
pool.destroy();
}
main();
Workers vs child processes vs clustering
| Feature | Worker Threads | Child Process | Cluster |
|---|---|---|---|
| Memory sharing | Yes (SharedArrayBuffer) | No | No |
| Communication | Fast (postMessage) | IPC (serialization) | IPC |
| Overhead | Low | High (new V8 instance) | High |
| Use case | CPU-bound tasks | Isolation, different languages | Scaling HTTP servers |
| Module sharing | Same context possible | Separate process | Separate process |
Use worker threads for CPU-bound JavaScript tasks that benefit from shared memory. Use child processes when you need full process isolation or need to run non-JavaScript programs. Use clustering when you want to scale an HTTP server across CPU cores.
Real-world example: image processing server
// server.js
const http = require('http');
const ThreadPool = require('./thread-pool');
const pool = new ThreadPool('./image-worker.js', 4);
http.createServer(async (req, res) => {
if (req.url === '/resize') {
try {
const result = await pool.run({
inputPath: './large-image.jpg',
width: 200,
height: 200
});
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
res.end(Buffer.from(result.data));
} catch (err) {
res.writeHead(500);
res.end('Processing failed');
}
}
}).listen(3000);
The server remains responsive to all requests because image processing happens on worker threads, not the main thread.
Summary
Worker threads let you run CPU-intensive JavaScript in parallel without blocking the event loop. Use postMessage for simple communication and SharedArrayBuffer with Atomics when you need high-performance shared memory. Build a thread pool to amortize the cost of creating workers. Workers complement Node.js’s async IO model: use async IO for network and file operations, and workers for computation.
Related articles
- Node.js Node.js Worker Threads: A Practical Guide for CPU-Intensive Tasks
Master worker threads in Node.js to offload CPU-heavy operations. Covers thread pools, SharedArrayBuffer, and real-world patterns.
- Node.js Event Emitters in Node.js
Understand the EventEmitter pattern in Node.js. Covers custom emitters, error handling, async events, memory leaks, and real-world patterns.
- Node.js Node.js Native Test Runner
Learn to write tests with Node.js built-in test runner. Covers test organization, assertions, mocking, subtests, code coverage, and watch mode.
- Node.js Node.js Streams: A Practical Guide
Master Node.js streams for efficient data processing. Covers readable, writable, transform, and duplex streams with real-world examples.