Skip to content

Node.js Worker Threads: When and How to Use Them in 2026

Offloading CPU Work With Node.js Worker Threads

A practical guide to Node.js worker threads for CPU-bound tasks, with a real example of moving image processing off the main event loop.

· · 4 min read

Quick Take

Node.js is single-threaded for your code, and a CPU-heavy function blocks every other request while it runs. I moved an image-resize step into a worker thread and watched p99 latency on unrelated endpoints drop by half.

The Node.js event loop is famously single-threaded, and that's fine for I/O, which was never actually blocking the thread to begin with. It stops being fine the moment you run something CPU-heavy: resizing an image, parsing a huge JSON payload, compressing a file. That work runs synchronously on the one thread handling every other request, and everything else waits.

TL;DR: Worker threads run JavaScript on a separate OS thread within the same Node.js process, sharing memory efficiently and freeing the main thread to keep handling requests. Use them for CPU-bound synchronous work (image processing, heavy parsing, cryptographic hashing), not for I/O, which the event loop already handles asynchronously. Reuse a pool of workers sized to your CPU core count instead of spawning one per task.

Proving the Problem First

Before reaching for worker threads, confirm you actually have a CPU-bound bottleneck. This synchronous function blocks the event loop for its entire duration, no other request gets processed until it returns:

function computeExpensiveHash(data: Buffer): string {
  let hash = 0;
  for (let i = 0; i < data.length; i++) {
    hash = (hash * 31 + data[i]) >>> 0; // deliberately synchronous, CPU-bound
  }
  return hash.toString(16);
}

// This blocks every other request on the server while it runs
app.post('/upload', (req, res) => {
  const hash = computeExpensiveHash(req.body);
  res.json({ hash });
});

If a health-check endpoint on the same server slows down under load whenever /upload is hit, that's the symptom. async/await doesn't fix this, the function itself is synchronous CPU work, wrapping it in a promise doesn't move it off the thread.

Moving the Work to a Worker Thread

// hash-worker.ts
import { parentPort, workerData } from 'node:worker_threads';

function computeExpensiveHash(data: Buffer): string {
  let hash = 0;
  for (let i = 0; i < data.length; i++) {
    hash = (hash * 31 + data[i]) >>> 0;
  }
  return hash.toString(16);
}

const result = computeExpensiveHash(workerData.buffer);
parentPort?.postMessage(result);
// main.ts
import { Worker } from 'node:worker_threads';
import path from 'node:path';

function hashInWorker(data: Buffer): Promise<string> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.resolve(import.meta.dirname, 'hash-worker.js'), {
      workerData: { buffer: data },
    });
    worker.once('message', resolve);
    worker.once('error', reject);
  });
}

app.post('/upload', async (req, res) => {
  const hash = await hashInWorker(req.body);
  res.json({ hash });
});

The main thread now awaits a promise that resolves when the worker posts its result back, and every other request keeps flowing through the event loop while that computation runs on a separate thread entirely.

Building a Worker Pool Instead of Spawning Per-Request

Spawning a new Worker per request has real overhead, tens of milliseconds of startup cost per worker. Under load, that overhead compounds. A pool that reuses a fixed number of long-lived workers, dispatching tasks to whichever is free, avoids it:

import { Worker } from 'node:worker_threads';
import os from 'node:os';
import path from 'node:path';

class WorkerPool {
  #workers: Worker[] = [];
  #queue: Array<{ data: Buffer; resolve: (v: string) => void }> = [];
  #freeWorkers: Worker[] = [];

  constructor(workerFile: string, size = os.cpus().length) {
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerFile);
      worker.on('message', result => {
        const task = this.#queue.shift();
        task?.resolve(result);
        this.#freeWorkers.push(worker);
        this.#runNext();
      });
      this.#workers.push(worker);
      this.#freeWorkers.push(worker);
    }
  }

  run(data: Buffer): Promise<string> {
    return new Promise(resolve => {
      this.#queue.push({ data, resolve });
      this.#runNext();
    });
  }

  #runNext() {
    if (this.#queue.length === 0 || this.#freeWorkers.length === 0) {return;}
    const worker = this.#freeWorkers.shift()!;
    const task = this.#queue[0];
    worker.postMessage(task.data);
  }
}

const pool = new WorkerPool(path.resolve(import.meta.dirname, 'hash-worker.js'));

Sizing the pool to os.cpus().length means every core stays busy under sustained load without oversubscribing the CPU with more threads than there are cores to run them.

Worker Threads vs Child Processes vs the libuv Thread Pool

ApproachShares memoryStartup costBest for
Worker threadsYes (SharedArrayBuffer)Moderate (tens of ms)CPU-bound JS computation
Child processesNo, serialized IPC onlyHighRunning external programs, isolation for crash safety
libuv thread pool (built-in)N/A, internal to NodeNone, already runningFile I/O, DNS lookups, some crypto (handled automatically)

Node already uses a thread pool internally for things like fs operations and crypto.pbkdf2, you don't manage that pool yourself. Worker threads are for the CPU-bound work you write, not the I/O Node already parallelizes for you.

Conclusion

Worker threads solve one specific problem: synchronous CPU-bound JavaScript blocking the single event loop thread. If your bottleneck is I/O, network calls, database queries, file reads, worker threads won't help, the event loop already handles those efficiently. Profile first, confirm the block is CPU-bound, then move that specific function into a pooled worker rather than spawning one per request.

Frequently Asked Questions

What's the difference between worker threads and child processes in Node.js?
A worker thread shares memory with the main process through SharedArrayBuffer and runs in the same process, making startup faster and communication cheaper. A child process (spawned via child_process) runs as a fully separate OS process with its own memory space, communicating only through serialized messages or pipes. Use worker threads for CPU-bound JavaScript computation; use child processes for running a separate program, a shell command, a Python script, something outside Node's own runtime.
Do worker threads help with I/O-bound work?
No, and using them for it wastes resources. Node's event loop already handles I/O, file reads, network requests, database queries, asynchronously and efficiently without blocking, because those operations are handed off to the OS or libuv's thread pool under the hood. Worker threads solve a different problem: synchronous, CPU-heavy JavaScript that would otherwise block the single main thread. Adding a worker thread around a fetch() call adds overhead with no benefit.
How many worker threads should I create?
Match the number of CPU cores available, checked via os.cpus().length, and reuse them through a worker pool rather than spawning a new worker per request. Worker threads have real startup cost, tens of milliseconds, so spawning one per incoming request under load creates its own bottleneck. A pool of workers sized to your core count, with tasks queued and dispatched to whichever worker is free, is the pattern that scales.