Node.js Worker Threads — Parallel CPU Processing with TypeScript 2026

Sanjeev SharmaSanjeev Sharma
7 min read

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

ConcernWorker ThreadsCluster
Use caseCPU-bound tasksI/O-bound HTTP traffic
MemoryShared (SharedArrayBuffer)Separate per process
CommunicationpostMessage (in-process)IPC (inter-process)
Crash isolationSingle process crashes allEach process isolated
Startup costLow (shared VM heap)High (new process)
Port sharingNoYes (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 .ts files directly — workers need .js paths. Compile TypeScript first, or use a loader like tsx.
  • Not handling worker crashes — a worker that throws an unhandled exception terminates with a non-zero exit code. Always listen for the exit event.
  • 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().length for CPU-bound work; over-threading adds context switching overhead.
  • Use workerData for initial configuration and postMessage for per-task data.
  • Handle the error event 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.
  • SharedArrayBuffer enables zero-copy memory sharing between threads; Atomics provides 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 error and exit events on workers and replace crashed workers to maintain pool integrity.
  • Pool size should equal os.cpus().length for 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading