Node.js worker threads let you run CPU-heavy tasks without blocking the event loop
← Back
April 4, 2026NodeJS7 min read

Node.js worker threads let you run CPU-heavy tasks without blocking the event loop

Published April 4, 20267 min read

I had a Node.js API that generated PDF reports and the event loop would freeze for 3-4 seconds on each request. The HTTP server became unresponsive — other requests queued up. Worker threads fixed this by moving the CPU work to a separate thread while the event loop stayed free to handle other requests. Here is the complete pattern.

Why Node.js blocks on CPU work

Node.js uses a single thread for JavaScript execution. The event loop processes I/O callbacks, timers, and your code. When synchronous CPU-heavy code runs, it holds the thread — nothing else can execute until it finishes.

Workers solve this by running JavaScript in separate threads with their own event loops, communicating via message passing.

Simple worker setup

typescript
// worker.ts — runs in a separate thread
import { workerData, parentPort } from 'worker_threads';

// This is the CPU-heavy work
function generateReport(data: ReportData): Buffer {
  // ... expensive PDF generation
  return pdfBuffer;
}

const result = generateReport(workerData);
parentPort!.postMessage({ result });

// main.ts — creates workers from the main thread
import { Worker } from 'worker_threads';
import path from 'path';

function runWorker(
  workerFile: string,
  data: unknown
): Promise {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.resolve(workerFile), {
      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}`));
      }
    });
  });
}

// Usage in an Express/Fastify handler
app.post('/reports', async (req, res) => {
  // This does NOT block the event loop — returns immediately
  const pdfBuffer = await runWorker(
    './workers/report-worker.js',
    req.body
  );
  
  res.setHeader('Content-Type', 'application/pdf');
  res.send(pdfBuffer);
});

Worker pool for repeated tasks

typescript
import { Worker } from 'worker_threads';
import path from 'path';

interface WorkerTask {
  resolve: (value: T) => void;
  reject: (reason: unknown) => void;
  data: unknown;
}

class WorkerPool {
  private workers: Worker[] = [];
  private freeWorkers: Worker[] = [];
  private queue: WorkerTask[] = [];

  constructor(
    private workerFile: string,
    private size: number = 4,
  ) {
    for (let i = 0; i < size; i++) {
      this.addWorker();
    }
  }

  private addWorker() {
    const worker = new Worker(path.resolve(this.workerFile));
    
    worker.on('message', (result: T) => {
      const task = this.currentTasks.get(worker);
      if (task) {
        this.currentTasks.delete(worker);
        task.resolve(result);
      }
      
      // Take next task from queue
      if (this.queue.length > 0) {
        const next = this.queue.shift()!;
        this.runTask(worker, next);
      } else {
        this.freeWorkers.push(worker);
      }
    });

    worker.on('error', (err) => {
      const task = this.currentTasks.get(worker);
      if (task) task.reject(err);
    });

    this.workers.push(worker);
    this.freeWorkers.push(worker);
  }

  private currentTasks = new Map>();

  private runTask(worker: Worker, task: WorkerTask) {
    this.currentTasks.set(worker, task);
    worker.postMessage(task.data);
  }

  run(data: unknown): Promise {
    return new Promise((resolve, reject) => {
      const task = { resolve, reject, data };
      
      if (this.freeWorkers.length > 0) {
        const worker = this.freeWorkers.pop()!;
        this.runTask(worker, task);
      } else {
        this.queue.push(task);
      }
    });
  }

  async terminate() {
    await Promise.all(this.workers.map(w => w.terminate()));
  }
}

// Create a pool of 4 report workers at startup
const reportPool = new WorkerPool('./workers/report-worker.js', 4);

// Concurrent PDF generation — all handled by the pool
const reports = await Promise.all([
  reportPool.run(report1Data),
  reportPool.run(report2Data),
  reportPool.run(report3Data),
]);

Sharing memory with SharedArrayBuffer

typescript
// For high-performance use cases, share memory between threads
// instead of copying data via postMessage

// main.ts
const sharedBuffer = new SharedArrayBuffer(4 * 1024 * 1024); // 4MB
const sharedArray = new Float64Array(sharedBuffer);

// Fill with data in the main thread
for (let i = 0; i < sharedArray.length; i++) {
  sharedArray[i] = Math.random();
}

// Pass the buffer reference (not a copy) to the worker
const worker = new Worker('./compute-worker.js', {
  workerData: { buffer: sharedBuffer, length: sharedArray.length }
});

// compute-worker.ts
import { workerData, parentPort } from 'worker_threads';

const array = new Float64Array(workerData.buffer);

// Both threads see the same memory — no data copying
let sum = 0;
for (let i = 0; i < workerData.length; i++) {
  sum += array[i];
}

parentPort!.postMessage({ sum });

When to use workers vs child_process

  • Workers: CPU-bound JavaScript code you want to run in parallel. Lower overhead, shared memory possible.
  • child_process: External processes (shell commands, other languages). Isolated, more memory.
  • Neither: I/O-bound work (database queries, HTTP calls) — Node.js handles these asynchronously on the same thread just fine. Workers only help for CPU work.
Share this
← All Posts7 min read