System theme
Node.js

Stop blocking Node's event loop with worker_threads – a practical guide

Stop blocking Node's event loop with worker_threads – a practical guide. Learn how to use worker threads correctly, avoid common pitfalls like ArrayBuffer copyi

KK13 · 1 min read

Why the event loop blocks when you spin up a Worker for CPU‑intensive work

Node's event loop handles I/O operations and executes JavaScript code on a single thread. When you run CPU‑bound work directly in the main process, the event loop stalls while that work completes. The solution is to move that work into a separate thread using worker_threads, but this introduces its own complexities.

Minimal reproducible example: compute‑heavy function in a worker and parentPort messaging

// index.js
const { parentPort, workerData } = require('node:worker_threads');

function computeIntensive(data) {
  // Simulate heavy calculation
  let result = 0;
  for (let i = 0; i < 1e9; i++) {
    result += Math.random();
  }
  return result;
}

parentPort.postMessage(computeIntensive(workerData));
// main.js
const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');

function heavyTask(input) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(__filename, {
      workerData: input,
      // Don't inherit this thread's environment
      env: { ...process.env }
    });
    
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

async function run() {
  const start = Date.now();
  const result = await heavyTask('large dataset');
  console.log(`Completed in ${Date.now() - start}ms, result: ${result}`);
}

if (isMainThread) run();

Common pitfall: forgetting to transfer ArrayBuffers – copy‑on‑write overhead and its cost

When data moves from the main thread to a worker thread, Node creates a copy unless you explicitly transfer ownership:

// ❌ Copy-on-write overhead
code1 = new Float64Array(1000000);
worker.postMessage(code1); // Creates a new buffer
code1.fill(0); // Original buffer unaffected

// ✅ Transfer ownership
code2 = new Float64Array(1000000);
const transferred = new MessageChannel();
worker.postMessage(code2, [code2.buffer]); // Transfers ownership
code2.fill(0); // Now affects the worker’s buffer

Each copy doubles memory usage for large buffers. Transferring avoids duplication but requires tracking what you're giving up.

What breaks when you ignore back‑pressure: memory growth, GC pressure, possible process crash

// Bad worker pool design without back-pressure handling
class WorkerPool {
  constructor(size) {
    this.workers = Array(size).fill(null).map(() => new Worker(__filename));
    this.pending = [];
    this.workers.forEach((worker, i) => {
      worker.on('message', (result) => {
        const { resolve } = this.pending.shift();
        resolve(result);
      });
    });
  }
  
  async execute(task) {
    // No limit on pending tasks
    return new Promise(resolve => {
      this.pending.push({ resolve });
      // Immediate post without checking capacity
      this.workers[0].postMessage(task);
    });
  }
}

What breaks:

  • Unbounded memory growth as pending tasks accumulate
  • GC pressure from many in‑flight buffers and promises
  • Process crash when hitting OS file descriptor limits
  • Event loop starvation from too many concurrent workers

How to structure a safe worker‑thread pattern (setup, task queue, result handling, cleanup)

class SafeWorkerPool {
  constructor(workerFile, options = {}) {
    this.workerFile = workerFile;
    this.poolSize = options.poolSize || 2;
    this.maxQueueSize = options.maxQueueSize || 100;
    this.workers = [];
    this.taskQueue = [];
    this.isShuttingDown = false;
    this.activeWorkers = 0;
  }
  
  init() {
    for (let i = 0; i < this.poolSize; i++) {
      const worker = new Worker(this.workerFile, {
        // Isolate worker environment
        env: { NODE_ENV: process.env.NODE_ENV },
        // Resource limits
        resourceLimits: {
          maxYoungGenerationSizeMb: 512,
          maxOldGenerationSizeMb: 1024
        }
      });
      worker.on('message', (result) => this.handleResult(worker, result));
      worker.on('error', (error) => this.handleWorkerError(worker, error));
      worker.on('exit', (code) => this.handleWorkerExit(worker, code));
      this.workers.push(worker);
    }
    // Start workers with initial tasks
    this.workers.forEach(w => this.dispatchNextTask());
  }
  
  async execute(task) {
    if (this.isShuttingDown) {
      throw new Error('WorkerPool is shutting down');
    }
    if (this.taskQueue.length >= this.maxQueueSize) {
      throw new Error('Task queue is full');
    }
    
    return new Promise((resolve, reject) => {
      this.taskQueue.push({ task, resolve, reject });
      this.dispatchNextTask();
    });
  }
  
  dispatchNextTask() {
    if (this.activeWorkers >= this.poolSize || this.taskQueue.length === 0) {
      return;
    }
    const { task, resolve, reject } = this.taskQueue.shift();
    this.activeWorkers++;
    
    const worker = this.workers.find(w => !w.exited);
    if (!worker) {
      this.activeWorkers--;
      return reject(new Error('No healthy workers available'));
    }
    
    const timeout = setTimeout(() => {
      this.activeWorkers--;
      worker.postMessage({ type: 'ABORT', task });
      reject(new Error('Worker timeout'));
    }, 30000);
    
    worker.on('message', (result) => {
      clearTimeout(timeout);
      this.activeWorkers--;
      resolve(result);
      this.dispatchNextTask();
    });
    
    worker.postMessage(task);
  }
  
  handleResult(worker, result) {
    if (result.type === 'ABORT') {
      console.warn('Task was aborted');
      return;
    }
    console.log('Task completed:', result.id);
  }
  
  handleWorkerError(worker, error) {
    console.error('Worker error:', error);
    // Replace failed worker
    const index = this.workers.indexOf(worker);
    this.replaceWorker(index);
  }
  
  handleWorkerExit(worker, code) {
    console.log('Worker exited with code:', code);
    const index = this.workers.indexOf(worker);
    this.workers[index] = null;
    this.activeWorkers--;
    // Replace exited worker
    setTimeout(() => this.replaceWorker(index), 1000);
  }
  
  replaceWorker(index) {
    if (this.isShuttingDown) return;
    const worker = this.workers[index];
    if (worker && !worker.exited) {
      worker.terminate();
    }
    const newWorker = new Worker(this.workerFile, {
      env: { NODE_ENV: process.env.NODE_ENV }
    });
    newWorker.on('message', (result) => this.handleResult(newWorker, result));
    newWorker.on('error', (error) => this.handleWorkerError(newWorker, error));
    newWorker.on('exit', (code) => this.handleWorkerExit(newWorker, code));
    this.workers[index] = newWorker;
    this.activeWorkers++;
    this.dispatchNextTask();
  }
  
  async shutdown() {
    this.isShuttingDown = true;
    // Signal all workers to stop
    this.workers.forEach(w => w.postMessage({ type: 'STOP' }));
    // Wait for graceful termination
    await Promise.all(
      this.workers.map(w => new Promise(resolve => w.on('exit', resolve)))
    );
  }
}

Quick checklist for production use (LTS Node, pool size, error forwarding, no global state)

  • Use LTS Node version for stability and long‑term support
  • Configure pool size based on CPU cores, not arbitrary numbers
  • Forward all errors with worker.on('error') handlers
  • Avoid global state in workers – each worker should be stateless
  • Use worker.terminate() in shutdown sequences
  • Set reasonable timeouts for individual tasks
  • Monitor worker health and restart failing workers
  • Test with realistic load before production deployment
  • Validate ArrayBuffer transfers to prevent memory duplication
  • Implement graceful degradation under back‑pressure

Use node --experimental-loader for memory profiling and event loop monitoring in production deployments.

Next in Node.js

Import built-in Node modules with the 'node:' protocol – why it matters

The 'node:' prefix prevents typosquatting attacks when importing core modules. Learn why this security improvement matters and how to migrate your code.

22 Sept 2026 · 2 min read