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.
What you'll learn
- ✓Why worker threads exist and when to use them
- ✓How to build a reusable thread pool
- ✓Sharing memory between threads with SharedArrayBuffer
- ✓Practical patterns for image processing and hashing
Prerequisites
- •Basic Node.js knowledge
- •Understanding of the event loop
- •Familiarity with Promises and async/await
Node.js is single-threaded by design, which makes it excellent for I/O-bound workloads but problematic when you need to crunch numbers, compress files, or process images. Worker threads let you spin up real OS threads that run JavaScript in parallel without blocking the main event loop. This guide walks through practical patterns you will actually use in production.
Why not just use child processes?
Child processes (child_process.fork) spawn entirely separate Node.js instances with their own V8 heap. They work, but they are expensive. Each child process consumes 30-50 MB of memory just to start. Worker threads share the same process, can share memory via SharedArrayBuffer, and have much lower overhead.
Use child processes when you need full isolation or want to run a different executable. Use worker threads when you need parallel JavaScript execution within the same application.
Basic worker communication
The fundamental pattern is simple: the main thread creates a worker, sends it data, and receives results back.
main.js:
import { Worker } from 'node:worker_threads';
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: data,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
const result = await runWorker({ numbers: [1, 2, 3, 4, 5] });
console.log('Sum:', result.sum);
worker.js:
import { parentPort, workerData } from 'node:worker_threads';
const sum = workerData.numbers.reduce((a, b) => a + b, 0);
parentPort.postMessage({ sum });
Data passed through workerData and postMessage is serialized using the structured clone algorithm. This means you can send objects, arrays, Maps, Sets, and even ArrayBuffers, but not functions or class instances with methods.
Building a thread pool
Creating a new worker for every task is wasteful. In production, you want a pool of pre-warmed workers that pick up tasks from a queue.
import { Worker } from 'node:worker_threads';
import { cpus } from 'node:os';
class ThreadPool {
#workers = [];
#queue = [];
#activeWorkers = 0;
constructor(workerPath, poolSize = cpus().length) {
this.workerPath = workerPath;
this.poolSize = poolSize;
}
run(taskData) {
return new Promise((resolve, reject) => {
const task = { taskData, resolve, reject };
if (this.#activeWorkers < this.poolSize) {
this.#runTask(task);
} else {
this.#queue.push(task);
}
});
}
#runTask(task) {
this.#activeWorkers++;
const worker = new Worker(this.workerPath, {
workerData: task.taskData,
});
worker.on('message', (result) => {
task.resolve(result);
this.#activeWorkers--;
if (this.#queue.length > 0) {
this.#runTask(this.#queue.shift());
}
});
worker.on('error', (err) => {
task.reject(err);
this.#activeWorkers--;
if (this.#queue.length > 0) {
this.#runTask(this.#queue.shift());
}
});
}
async close() {
// Wait for all tasks to finish
while (this.#activeWorkers > 0 || this.#queue.length > 0) {
await new Promise((r) => setTimeout(r, 100));
}
}
}
Usage:
const pool = new ThreadPool('./hash-worker.js', 4);
const results = await Promise.all(
files.map((file) => pool.run({ filePath: file }))
);
await pool.close();
Sharing memory with SharedArrayBuffer
The real power of worker threads over child processes is shared memory. SharedArrayBuffer lets multiple threads read and write the same block of memory without copying.
// main.js
import { Worker } from 'node:worker_threads';
const sharedBuffer = new SharedArrayBuffer(4 * 1024); // 4KB
const view = new Int32Array(sharedBuffer);
// Initialize data
for (let i = 0; i < view.length; i++) {
view[i] = i;
}
const worker = new Worker('./transform-worker.js', {
workerData: { buffer: sharedBuffer },
});
worker.on('message', () => {
// Data was modified in place — no copy needed
console.log('First 5 values:', Array.from(view.slice(0, 5)));
});
// transform-worker.js
import { parentPort, workerData } from 'node:worker_threads';
const view = new Int32Array(workerData.buffer);
// Double every value in place
for (let i = 0; i < view.length; i++) {
view[i] *= 2;
}
parentPort.postMessage('done');
When multiple threads write to the same shared buffer, you need synchronization. Use Atomics to prevent race conditions:
// Atomic increment — safe across threads
Atomics.add(view, index, 1);
// Wait until a value changes
Atomics.wait(view, index, expectedValue);
// Notify waiting threads
Atomics.notify(view, index, 1);
Real-world example: parallel image hashing
Here is a practical scenario where you need to compute SHA-256 hashes for a batch of uploaded files without blocking the server.
// hash-worker.js
import { parentPort, workerData } from 'node:worker_threads';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
const { filePath } = workerData;
const content = await readFile(filePath);
const hash = createHash('sha256').update(content).digest('hex');
parentPort.postMessage({ filePath, hash });
// server.js
import express from 'express';
import { ThreadPool } from './thread-pool.js';
const pool = new ThreadPool('./hash-worker.js', 4);
const app = express();
app.post('/upload', async (req, res) => {
const filePaths = req.body.files;
const hashes = await Promise.all(
filePaths.map((fp) => pool.run({ filePath: fp }))
);
res.json({ hashes });
});
app.listen(3000);
The server stays responsive because all the file reading and hashing happens on worker threads while the main thread continues handling HTTP requests.
Transferring ownership with transferList
When passing large ArrayBuffers between threads, you can transfer ownership instead of copying. This is a zero-copy operation.
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
// Transfer — buffer becomes unusable in the main thread
worker.postMessage({ data: buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — ownership transferred
This is critical for performance when dealing with large binary data like images or video frames.
When to avoid worker threads
Worker threads are not a silver bullet. Avoid them for:
- I/O-bound work: The event loop already handles this efficiently with non-blocking I/O. Adding worker threads for database queries or HTTP requests adds complexity without benefit.
- Simple tasks: If the computation takes less than a few milliseconds, the overhead of creating and communicating with a worker is not worth it.
- Tasks requiring the full Node.js API: Workers have access to most Node.js APIs, but some modules behave differently or are unavailable.
Good use cases include image processing, PDF generation, data compression, cryptographic operations, CSV/JSON parsing of large files, and machine learning inference.
Key takeaways
Worker threads fill a specific gap in Node.js: CPU-intensive parallelism without the overhead of child processes. Use a thread pool pattern in production, leverage SharedArrayBuffer when you need zero-copy data sharing, and remember that for I/O-bound work, the event loop is still your best tool. Start with os.cpus().length workers and benchmark from there.
Related articles
- Node.js 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.
- Node.js Node Worker Threads vs Cluster
When to reach for worker_threads versus cluster in Node.js, with a clear mental model, real code, and the pitfalls that bite people in production.
- Node.js Node.js vs Deno vs Bun: JavaScript Runtimes Compared
Compare Node.js, Deno, and Bun on performance, security, compatibility, and developer experience. Choose the right JavaScript runtime for 2026.
- Node.js Node.js Cluster Module for Multi-Core Scaling
Scale your Node.js application across all CPU cores using the cluster module. Covers load balancing, zero-downtime restarts, and production patterns.