Node.js Worker Threads — Parallel CPU Processing with TypeScript 2026
Advertisement
Introduction
Why This Matters
Node.js is single-threaded by design — ideal for I/O-bound work but problematic for CPU-intensive operations. A single heavy computation (image resizing, PDF generation, cryptography, ML inference) blocks the event loop and freezes all concurrent requests.
Worker threads, introduced in Node.js 10 and stable since Node.js 12, provide true OS-level threads within a single process. They share memory but run JavaScript independently — enabling parallelism without the process overhead of clustering.
How Worker Threads Work
Each worker thread runs its own V8 instance and event loop. Threads communicate via message passing, and can share memory through SharedArrayBuffer for zero-copy transfers.
// main.ts
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
import path from 'path';
if (isMainThread) {
function runWorker(data: { input: number }): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(path.resolve(__dirname, 'worker.js'), {
workerData: data,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with code ${code}`));
}
});
});
}
const result = await runWorker({ input: 42 });
console.log('Result:', result);
}// worker.ts — compiled to worker.js
import { parentPort, workerData } from 'worker_threads';
function heavyComputation(n: number): number {
let result = 0;
for (let i = 0; i < n * 1_000_000; i++) {
result += Math.sqrt(i);
}
return result;
}
const result = heavyComputation(workerData.input as number);
parentPort?.postMessage(result);Note: Workers receive the compiled .js file path. When using TypeScript, compile first or use ts-node with the --workerThreads flag, or use tsx.
Building a Typed Worker Thread Pool
Spawning a new worker per task is expensive. A thread pool reuses workers across tasks, dramatically reducing overhead.
import { Worker } from 'worker_threads';
import path from 'path';
import os from 'os';
interface Task<T, R> {
data: T;
resolve: (result: R) => void;
reject: (err: Error) => void;
}
class WorkerPool<T, R> {
private workers: Worker[] = [];
private queue: Task<T, R>[] = [];
private idle: Worker[] = [];
constructor(
private workerPath: string,
private size: number = os.cpus().length
) {
for (let i = 0; i < this.size; i++) {
this.addWorker();
}
}
private addWorker(): void {
const worker = new Worker(this.workerPath);
worker.on('message', (result: R) => {
const task = this.queue.shift();
if (task) {
worker.postMessage(task.data);
} else {
this.idle.push(worker);
}
// Resolve the task that just finished
// (stored via closure in run())
});
worker.on('error', (err) => {
console.error('Worker error:', err);
this.addWorker(); // Replace crashed worker
});
this.idle.push(worker);
}
run(data: T): Promise<R> {
return new Promise<R>((resolve, reject) => {
const idleWorker = this.idle.pop();
if (idleWorker) {
idleWorker.once('message', resolve);
idleWorker.once('error', reject);
idleWorker.postMessage(data);
} else {
this.queue.push({ data, resolve, reject });
}
});
}
async destroy(): Promise<void> {
await Promise.all(this.workers.map((w) => w.terminate()));
}
}
// Usage
const pool = new WorkerPool<{ n: number }, number>(
path.resolve(__dirname, 'compute-worker.js'),
os.cpus().length
);
const results = await Promise.all(
[10, 20, 30, 40].map((n) => pool.run({ n }))
);
console.log(results);Zero-Copy Data Sharing with SharedArrayBuffer
postMessage copies data between threads by default (structured clone). For large buffers (images, audio), use SharedArrayBuffer or transfer ArrayBuffer to avoid copying.
// main.ts — share a large buffer with a worker
import { Worker } from 'worker_threads';
const SIZE = 1024 * 1024; // 1 MB
const shared = new SharedArrayBuffer(SIZE);
const view = new Int32Array(shared);
// Fill with data
for (let i = 0; i < view.length; i++) {
view[i] = i;
}
const worker = new Worker('./process-worker.js', {
workerData: { shared },
});
worker.on('message', () => {
console.log('First value after worker processing:', view[0]);
});// process-worker.ts
import { parentPort, workerData } from 'worker_threads';
const view = new Int32Array(workerData.shared as SharedArrayBuffer);
// Process in-place, no copying
for (let i = 0; i < view.length; i++) {
view[i] = view[i] * 2;
}
parentPort?.postMessage('done');Use Atomics for synchronization when multiple threads write to the same SharedArrayBuffer simultaneously.
Worker Threads vs Cluster: Choosing the Right Tool
| Concern | Worker Threads | Cluster |
|---|---|---|
| Use case | CPU-bound tasks | I/O-bound HTTP traffic |
| Memory | Shared (SharedArrayBuffer) | Separate per process |
| Communication | postMessage (in-process) | IPC (inter-process) |
| Crash isolation | Single process crashes all | Each process isolated |
| Startup cost | Low (shared VM heap) | High (new process) |
| Port sharing | No | Yes (via cluster module) |
Use worker threads for compute: image processing, video transcoding, cryptographic hashing, JSON schema validation on large payloads. Use cluster for horizontal HTTP scaling across CPU cores.
Practical Example: Parallel Image Processing
import { WorkerPool } from './worker-pool';
import path from 'path';
interface ImageTask {
inputPath: string;
outputPath: string;
width: number;
height: number;
}
interface ImageResult {
outputPath: string;
durationMs: number;
}
const imagePool = new WorkerPool<ImageTask, ImageResult>(
path.resolve(__dirname, 'image-worker.js'),
4 // 4 worker threads for image processing
);
// Process 100 images in parallel, up to 4 at a time
const tasks: ImageTask[] = images.map((img) => ({
inputPath: img.path,
outputPath: img.outputPath,
width: 800,
height: 600,
}));
const results = await Promise.all(tasks.map((t) => imagePool.run(t)));
console.log(`Processed ${results.length} images`);Common Mistakes
- Running compiled
.tsfiles directly — workers need.jspaths. Compile TypeScript first, or use a loader liketsx. - Not handling worker crashes — a worker that throws an unhandled exception terminates with a non-zero exit code. Always listen for the
exitevent. - Spawning a new worker per request — worker startup overhead (~50ms) makes per-request workers unusable at scale. Always use a pool.
- Overusing SharedArrayBuffer without Atomics — concurrent writes without synchronization cause race conditions and data corruption.
- Sending large data via postMessage — structured clone copies data. Transfer ArrayBuffers or use SharedArrayBuffer for large payloads.
Best Practices
- Pool workers at application startup — keep the pool warm for the lifetime of the process.
- Size the pool to
os.cpus().lengthfor CPU-bound work; over-threading adds context switching overhead. - Use
workerDatafor initial configuration andpostMessagefor per-task data. - Handle the
errorevent on every worker and replace crashed workers automatically. - Profile the main thread after adding workers — if event loop lag disappears, the task was correctly offloaded.
- Use Piscina (a production-grade worker pool library) instead of rolling your own pool for complex use cases.
Key Takeaways
- Worker threads provide true OS-level parallelism in Node.js, running independent V8 instances that share process memory.
- Each worker thread has its own event loop and JavaScript execution context — they do not share closures or global state by default.
- A worker thread pool reuses threads across tasks, eliminating the ~50ms startup overhead of spawning per-task workers.
SharedArrayBufferenables zero-copy memory sharing between threads;Atomicsprovides synchronization primitives for concurrent writes.- Worker threads are optimized for CPU-bound work; the cluster module is optimized for I/O-bound HTTP traffic scaling.
- Always listen for the
errorandexitevents on workers and replace crashed workers to maintain pool integrity. - Pool size should equal
os.cpus().lengthfor CPU-bound tasks — additional threads increase context switching without improving throughput. - The Piscina library provides a production-ready worker pool with queue management, task timeout, and metrics out of the box.
Advertisement