0x55aa
โ† Back to Blog

๐Ÿงต Node.js Worker Threads: Stop Choking Your Event Loop with CPU Work

โ€ข6 min read

๐Ÿงต Node.js Worker Threads: Stop Choking Your Event Loop with CPU Work

Here's a scenario every Node.js developer has lived through: you deploy a perfectly good API, everything is fast and snappy, then someone adds a feature that does heavy computation โ€” image resizing, JSON parsing of a 50MB file, some complex number crunching โ€” and suddenly your entire server becomes unresponsive for two seconds every time that endpoint gets hit.

Welcome to the event loop. Population: you, crying.

The Fundamental Problem

Node.js runs JavaScript on a single thread. The event loop is brilliant for I/O โ€” waiting for database queries, HTTP responses, file reads โ€” because those things actually wait. The event loop just moves on to other work while it waits.

But CPU-bound work is different. If you're computing the 10,000th Fibonacci number or parsing a massive CSV file, the event loop doesn't wait โ€” it grinds. Every other request hitting your server sits in line, tapping its foot, while you crunch numbers.

// This blocks EVERYTHING for however long it takes
app.get('/process', (req, res) => {
  const result = parseHugeCsvFile(req.body.data); // ๐Ÿ˜ฌ blocking
  res.json({ result });
});

You can't await your way out of this. The computation is synchronous by nature. Promises don't help here.

Enter Worker Threads

Worker threads (available since Node.js 10.5, stable since 12) let you spin up separate JavaScript execution contexts โ€” actual OS threads โ€” that share memory with your main process but run independently. Think of them like hiring a specialist to handle the heavy lifting while you keep greeting customers at the front desk.

// worker.js โ€” runs in its own thread
const { workerData, parentPort } = require('worker_threads');

function processData(data) {
  // CPU-intensive work goes here
  let result = 0;
  for (let i = 0; i < data.iterations; i++) {
    result += Math.sqrt(i) * Math.sin(i);
  }
  return result;
}

const result = processData(workerData);
parentPort.postMessage({ result });
// main.js โ€” your Express app stays responsive
const { Worker } = require('worker_threads');
const path = require('path');

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

app.get('/compute', async (req, res) => {
  try {
    const result = await runWorker({ iterations: 10_000_000 });
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Now the heavy computation runs in a separate thread. Your event loop keeps humming, other requests get served, and the compute endpoint resolves whenever the worker finishes.

Worker Pools: Don't Spin Up a Thread for Every Request

Creating a new thread for every request is expensive โ€” there's overhead in spawning workers. For high-traffic endpoints, you want a worker pool: a set of pre-warmed workers sitting ready to handle jobs.

const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(workerScript, size = os.cpus().length) {
    this.workers = [];
    this.queue = [];

    for (let i = 0; i < size; i++) {
      this._addWorker(workerScript);
    }
  }

  _addWorker(script) {
    const worker = new Worker(script);
    worker.isIdle = true;

    worker.on('message', (result) => {
      worker.isIdle = true;
      worker._resolve(result);
      this._processQueue();
    });

    worker.on('error', (err) => {
      worker.isIdle = true;
      worker._reject(err);
      this._processQueue();
    });

    this.workers.push(worker);
  }

  _processQueue() {
    if (this.queue.length === 0) return;

    const idleWorker = this.workers.find((w) => w.isIdle);
    if (!idleWorker) return;

    const { data, resolve, reject } = this.queue.shift();
    idleWorker.isIdle = false;
    idleWorker._resolve = resolve;
    idleWorker._reject = reject;
    idleWorker.postMessage(data);
  }

  run(data) {
    return new Promise((resolve, reject) => {
      this.queue.push({ data, resolve, reject });
      this._processQueue();
    });
  }
}

// Initialize once at startup
const pool = new WorkerPool('./worker.js', 4);

app.get('/compute', async (req, res) => {
  const result = await pool.run({ iterations: 10_000_000 });
  res.json(result);
});

This pool pre-allocates 4 workers (or however many CPU cores you have) and queues jobs when all workers are busy. Threads stay alive between requests โ€” no spawn overhead per request.

When to Actually Use This

Worker threads are a scalpel, not a sledgehammer. Use them when you have:

  • Image or video processing โ€” resizing, format conversion, thumbnail generation
  • Cryptographic operations โ€” bcrypt hashing (though bcryptjs is async, native bcrypt blocks)
  • Large file parsing โ€” CSV, XML, JSON files over a few MB
  • Machine learning inference โ€” running models locally
  • Data transformation pipelines โ€” sorting, filtering, aggregating big datasets

Don't reach for worker threads because you think something might be slow. Profile first. If the bottleneck is a database query or an HTTP call โ€” that's async I/O, and workers won't help at all.

A Few Gotchas to Save You Hours

Worker threads don't share the same module scope. If you set a global in your main thread, the worker won't see it. Workers are isolated environments.

console.log works in workers but it goes to the same stdout. Don't rely on ordering.

You can share memory using SharedArrayBuffer and Atomics for high-performance scenarios โ€” but it's complex enough that you probably don't need it until you're doing very specialized work.

Worker threads are not the same as cluster mode. Clusters fork entire Node.js processes (great for handling more concurrent HTTP requests). Worker threads are threads within a single process (great for parallelizing CPU work within a single request). They solve different problems and work well together.

The Bottom Line

Node.js being single-threaded is a feature, not a bug โ€” 99% of the time. The event loop handles I/O-bound work with remarkable efficiency. But the moment you start doing real CPU work on your main thread, you're holding a live grenade every time a request comes in.

Worker threads are the release valve. They keep your event loop free, your server responsive, and your users from wondering why the entire API went dark for a few seconds.

If your Node.js app does anything compute-heavy, you now have no excuse.


Hit a situation where worker threads saved (or could have saved) your server? Drop it in the comments โ€” especially if you've built something clever with shared memory or worker pools in production. I'm always curious how people solve this in the real world.