DevLift
Back to Blog

Node.js Worker Threads: CPU-Intensive Work Without Blocking the Event Loop

Node.js is single-threaded — but Worker Threads let you move CPU-heavy tasks off the event loop so your server stays responsive under real load.

Admin
August 2, 20267 min read2 views

Node.js Worker Threads: CPU-Intensive Work Without Blocking the Event Loop

Your Node.js API is humming along, handling hundreds of requests per second. Then someone uploads a 50MB CSV that needs to be parsed and aggregated. Suddenly every request to your server starts timing out. The health check fails. Alerts fire. The event loop is stuck.

This is the classic Node.js performance trap: you write code that works fine in isolation but brings your entire server to a halt under real load. The culprit is almost always CPU-intensive synchronous work running on the main thread.

Worker Threads are the fix. They let you move that heavy computation off the event loop and into a real operating system thread, so your server keeps accepting requests while the work runs in the background.

Why the Event Loop Blocks

Node.js is single-threaded by design. That single thread runs your application code, processes I/O callbacks, handles timers, and manages everything else. When Node.js is waiting for a database query or a network response, it yields control and handles other events. That's the whole model.

The problem is synchronous CPU work doesn't yield. A tight loop, a complex regex, JSON parsing of a huge blob, image manipulation — these run on the thread, blocking everything else until they finish.

// This kills your server for however long it takes to complete
app.post('/process-data', (req, res) => {
  const result = heavyComputation(req.body.data); // seconds of pure CPU
  res.json(result); // No other requests are handled in the meantime
});
 
function heavyComputation(data) {
  let result = 0;
  for (let i = 0; i < 2_000_000_000; i++) {
    result += Math.sqrt(i) * data.factor;
  }
  return result;
}

Two billion iterations of that loop measured 1.9 seconds on Node 22.22.3 (200M took 193ms, so it's cleanly linear — scale to your own hardware). For the whole of it, your server handles zero other requests. Health checks fail. WebSocket connections time out. Users bounce.

child_process can help, but spawning a full process for each task has significant overhead (memory, startup time, IPC serialization costs). Worker Threads give you real threads — cheaper to spin up, able to share memory with the main thread, and purpose-built for this use case.

The Basic Worker Threads Pattern

The node:worker_threads module has been stable since Node.js 12. The API centers on two primitives:

  • Worker — creates a new thread running a script
  • parentPort — the communication channel between the worker and its parent

Here's the minimal pattern:

// main.js
import { Worker } from 'node:worker_threads';
 
function runWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./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({ numbers: [1, 2, 3, 4, 5] });
console.log(result); // { sum: 15, product: 120 }
// worker.js
import { parentPort, workerData } from 'node:worker_threads';
 
const { numbers } = workerData;
 
const sum = numbers.reduce((a, b) => a + b, 0);
const product = numbers.reduce((a, b) => a * b, 1);
 
parentPort.postMessage({ sum, product });

workerData carries the input — it's cloned using the structured clone algorithm, so the worker gets its own copy. parentPort.postMessage() sends the result back. The main thread picks it up in the message event and resolves the promise.

💡

workerData is cloned at thread creation time. If you need to send data after the worker starts, use worker.postMessage() and parentPort.on('message', ...) instead.

The Architecture

Rendering diagram...

The main thread stays free to handle incoming requests. Workers run on separate OS threads and report results back via message passing.

The Real Pattern: Worker Pools

Creating a new Worker for every task works, but it's wasteful. Each worker spawns a new V8 isolate — there's startup overhead. Under any real load, you want a pool of pre-warmed workers waiting to accept tasks.

Here's a worker pool. Read the error handling twice — that's where the first version of this went wrong.

// worker-pool.js
import { Worker } from 'node:worker_threads';
import { cpus } from 'node:os';
 
export class WorkerPool {
  #workers = [];
  #queue = [];
  #script;
  #size;
  #closed = false;
  #idleFailures = 0;          // guards against a respawn loop on a broken script
 
  constructor(script, size = cpus().length) {
    this.#script = script;
    this.#size = size;
    for (let i = 0; i < size; i++) {
      this.#spawnWorker();
    }
  }
 
  #spawnWorker() {
    // A script that can't even load will fail every replacement too. Respawning
    // unconditionally turns one bad path into an infinite worker-creation loop.
    if (this.#closed || this.#idleFailures > this.#size) return;
    const worker = new Worker(this.#script);
    const state = { worker, busy: false, resolve: null, reject: null };
    this.#workers.push(state);
 
    worker.on('message', (result) => {
      const { resolve } = this.#release(state);
      resolve?.(result);
      this.#drain();
    });
 
    // A worker can fail with no task assigned — a bad script path, a syntax
    // error in the worker file, OOM. `state.reject` is null in that case, so
    // calling it unconditionally throws inside an 'error' listener, which is
    // an uncaught exception that takes the whole process down.
    worker.on('error', (err) => {
      const { reject } = this.#release(state);
      if (reject) {
        reject(err);
      } else {
        this.#idleFailures++;
        console.error('[worker-pool] worker failed while idle:', err.message);
      }
      this.#retire(state);
      this.#spawnWorker();
      this.#drain();
    });
 
    // A worker that calls process.exit() emits 'exit' but never 'error' and
    // never 'message'. Without this the task's promise is never settled and
    // the caller hangs forever.
    worker.on('exit', (code) => {
      const { reject } = this.#release(state);
      if (reject) reject(new Error(`Worker exited with code ${code} mid-task`));
      if (this.#retire(state)) {
        this.#spawnWorker();
        this.#drain();
      }
    });
  }
 
  #release(state) {
    const { resolve, reject } = state;
    state.busy = false;
    state.resolve = null;
    state.reject = null;
    return { resolve, reject };
  }
 
  #retire(state) {
    const idx = this.#workers.indexOf(state);
    if (idx === -1) return false;         // already retired by a sibling event
    this.#workers.splice(idx, 1);
    return !this.#closed;
  }
 
  #drain() {
    while (this.#queue.length > 0) {
      const idle = this.#workers.find((w) => !w.busy);
      if (!idle) return;
 
      const { data, resolve, reject } = this.#queue.shift();
      idle.busy = true;
      idle.resolve = resolve;
      idle.reject = reject;
      idle.worker.postMessage(data);
    }
  }
 
  run(data) {
    if (this.#closed) return Promise.reject(new Error('Pool is terminated'));
    return new Promise((resolve, reject) => {
      this.#queue.push({ data, resolve, reject });
      this.#drain();
    });
  }
 
  async terminate() {
    this.#closed = true;
    for (const task of this.#queue.splice(0)) {
      task.reject(new Error('Pool terminated before task ran'));
    }
    await Promise.all(this.#workers.splice(0).map((s) => s.worker.terminate()));
  }
}
🚨

The naive version of #spawnWorker calls state.reject(err) directly in the 'error' handler. That works right up until a worker fails before it has been handed a task — point the pool at a script that doesn't exist and you get TypeError: state.reject is not a function, thrown from inside an event handler, uncaught, process dead. I hit this on the first run. The 'exit' handler matters for the same reason from the other direction: a worker that calls process.exit() leaves its task's promise pending forever, and "crashed workers get replaced" is no comfort to the request that's still waiting.

// worker-pool-worker.js — the script each pooled worker runs
import { parentPort } from 'node:worker_threads';
 
parentPort.on('message', (data) => {
  // Do the CPU work here
  const result = expensiveOperation(data);
  parentPort.postMessage(result);
});
 
function expensiveOperation(data) {
  // image resize, CSV parsing, encryption, etc.
  return { processed: true, ...data };
}
// app.js — using the pool in an Express server
import express from 'express';
import { WorkerPool } from './worker-pool.js';
 
const app = express();
const pool = new WorkerPool('./worker-pool-worker.js'); // defaults to CPU count
 
app.use(express.json());
 
app.post('/process', async (req, res) => {
  try {
    const result = await pool.run(req.body);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
 
// Clean up on shutdown
process.on('SIGTERM', () => pool.terminate());
 
app.listen(3000);

Workers get reused across requests. The queue ensures tasks wait for an available worker rather than creating unbounded parallelism. A worker that crashes gets replaced — and a task in flight on it gets rejected rather than hanging.

Here's the payoff measured rather than asserted. Four CPU tasks of 400M iterations each, on a 4-core box, Node 22.22.3, with monitorEventLoopDelay watching the main thread:

4x on main thread (blocking)     wall=1523ms   worst loop delay = 1523.6ms
4x via WorkerPool (4 workers)    wall= 447ms   worst loop delay =    3.1ms

Two separate wins, and it's worth keeping them apart. The wall time dropped 3.4x because four cores did the work instead of one — that's parallelism, and it only shows up if you have spare cores. The event loop delay dropped from 1.5 seconds to 3 milliseconds — that's the actual reason to do this. Even on a single core, where the wall time would get slightly worse, your health check still answers.

Size your pool to os.cpus().length. More workers than CPUs means context switching overhead that hurts rather than helps. For mixed workloads (some short tasks, some long), cpus().length - 1 keeps one core free for the event loop.

Sharing Memory with SharedArrayBuffer

Message passing clones data. For large datasets that's expensive. SharedArrayBuffer lets multiple threads read and write the same memory region without copying.

// main.js
import { Worker } from 'node:worker_threads';
 
const ARRAY_SIZE = 1_000_000;
const sharedBuffer = new SharedArrayBuffer(ARRAY_SIZE * Float64Array.BYTES_PER_ELEMENT);
const sharedArray = new Float64Array(sharedBuffer);
 
// Fill with data in main thread
for (let i = 0; i < ARRAY_SIZE; i++) {
  sharedArray[i] = Math.random();
}
 
// Workers can read (and write) the same buffer — no copying
const worker1 = new Worker('./half-sum-worker.js', {
  workerData: { sharedBuffer, start: 0, end: ARRAY_SIZE / 2 }
});
const worker2 = new Worker('./half-sum-worker.js', {
  workerData: { sharedBuffer, start: ARRAY_SIZE / 2, end: ARRAY_SIZE }
});
 
const [sum1, sum2] = await Promise.all([
  new Promise(resolve => worker1.on('message', resolve)),
  new Promise(resolve => worker2.on('message', resolve)),
]);
 
console.log('Total sum:', sum1 + sum2);
// half-sum-worker.js
import { parentPort, workerData } from 'node:worker_threads';
 
const { sharedBuffer, start, end } = workerData;
const array = new Float64Array(sharedBuffer);
 
let sum = 0;
for (let i = start; i < end; i++) {
  sum += array[i];
}
 
parentPort.postMessage(sum);
⚠️

SharedArrayBuffer doesn't come with automatic synchronization. If multiple workers write to overlapping regions simultaneously, you get data races. Use Atomics for coordinated writes, or partition work so each worker owns a non-overlapping slice.

Using Piscina Instead

If you don't want to maintain your own pool, Piscina is the standard library for this. It handles pooling, task queuing, backpressure, and TypeScript types:

npm install piscina
// main.js
import { Piscina } from 'piscina';   // named export — not a default import
 
const pool = new Piscina({
  filename: new URL('./image-worker.js', import.meta.url).href,
  maxThreads: 4,
  minThreads: 2,
});
 
// Automatically queued and dispatched to available workers
const [thumb1, thumb2] = await Promise.all([
  pool.run({ path: 'photo1.jpg', width: 200 }),
  pool.run({ path: 'photo2.jpg', width: 200 }),
]);
// image-worker.js — Piscina expects a default export function
import sharp from 'sharp';
 
export default async function({ path, width }) {
  const buffer = await sharp(path).resize(width).toBuffer();
  return buffer;
}

One thing not to assume: Piscina does not transfer buffers for you. Its docs are explicit that "any value returned by a worker function will be cloned when returned back to the Piscina pool, even if that object is capable of being transferred." If you're returning a 10MB ArrayBuffer from every task, you're paying for a full copy unless you opt in with Piscina.move():

// image-worker.js
import { move } from 'piscina';
import sharp from 'sharp';
 
export default async function ({ path, width }) {
  const buffer = await sharp(path).resize(width).toBuffer();
  return move(buffer.buffer);   // transferred, not cloned
}

move() throws if the value isn't transferable, and it only works at the top level — wrap it inside an object and the wrapper gets cloned instead, silently. Worth using Piscina anyway for the pool management and backpressure; just don't expect zero-copy by default.

Passing Data Back: Transfer vs Clone

By default, postMessage clones data using structured clone. For large ArrayBuffer or TypedArray, you can transfer ownership instead — the buffer moves to the receiving thread without copying:

// main.js — transfer the buffer to the worker (no copy)
const buffer = new ArrayBuffer(10 * 1024 * 1024); // 10MB
worker.postMessage({ buffer }, [buffer]); // second arg = transferables
 
// After this, buffer is detached in main thread — don't touch it
console.log(buffer.byteLength); // 0
 
// Worker sends result back via transfer
// worker.js — transfer result back to main (no copy)
import { parentPort } from 'node:worker_threads';
 
// The buffer arrived via postMessage, so it lands in a 'message' event.
// Reading it from `workerData` here gives you
//   TypeError: Cannot destructure property 'buffer' of 'workerData' as it is undefined
// because nothing was passed to the Worker constructor.
parentPort.on('message', ({ buffer }) => {
  const view = new Uint8Array(buffer);
 
  // ... process the data in-place ...
 
  // Transfer the buffer back
  parentPort.postMessage({ buffer }, [buffer]);
});

Two channels, two ways in: workerData for what you hand over at construction, parentPort.on('message') for everything sent afterwards. Mixing them up is the single most common way a worker script fails on its first run.

This pattern is useful for image processing pipelines where you pass large pixel arrays between threads.

When NOT to Use Worker Threads

Worker Threads get misapplied constantly. A few situations where they make things worse, not better:

I/O-bound work. Reading files, making HTTP requests, querying databases — Node.js handles these asynchronously already. The event loop is not blocked during I/O. Pushing I/O into a worker thread adds overhead for zero benefit.

Short-running computations. Creating a worker costs real time, though less than folklore says. Measured on Node 22.22.3 — new Worker(...) to first postMessage received, worker doing nothing but replying — the first spawn took 43ms and subsequent ones settled at a median of 18.5ms (min 18.1, max 27.9). So call it ~20ms warm, ~40ms cold, not the 50-100ms you'll see quoted.

That's still catastrophic for a 5ms task: you've made it 5x slower and added a thread. A pool amortises the spawn away, but never the message round-trip — structured cloning the input and output, plus two event-loop hops. As a rule of thumb, the task needs to cost more than a couple of milliseconds of CPU before offloading pays for itself.

Lots of small concurrent tasks. Workers are threads, not goroutines. You can't spin up 10,000 of them. If you have high-concurrency requirements with small per-task work, async/await on the main thread is the right model.

Quick array operations. Sorting a 1,000-element array, filtering a list, transforming a small JSON payload — these are microseconds. Don't reach for workers just because you see a loop.

The clearest signal that you need workers: you can see your event loop lag spike in metrics when specific requests come in. Get that from perf_hooks.monitorEventLoopDelay (p99, not p50 — blocking shows up entirely in the tail) and then find the frame with node --cpu-prof, which writes a .cpuprofile you can open in Chrome DevTools. If the main thread is saturated by one operation, that's the candidate to offload.

setEnvironmentData: Sharing Config Without Re-Passing

One underused feature: setEnvironmentData lets you broadcast key-value data to all workers spawned after the call, without repeating it in workerData:

import { setEnvironmentData } from 'node:worker_threads';
 
// Set once in the main process startup
setEnvironmentData('DB_CONNECTION_STRING', process.env.DB_URL);
setEnvironmentData('APP_CONFIG', JSON.stringify(config));
 
// Every worker spawned after this automatically has access
// Inside any worker
import { getEnvironmentData } from 'node:worker_threads';
 
const dbUrl = getEnvironmentData('DB_CONNECTION_STRING');
const config = JSON.parse(getEnvironmentData('APP_CONFIG'));

Useful for config that every worker needs but you don't want to serialize into every workerData call.

The Practical Checklist

Before adding Worker Threads:

  1. Measure first. Confirm the event loop is actually blocked, not just slow. perf_hooks.performance.eventLoopUtilization() gives you a ratio.
  2. Is it I/O or CPU? I/O → async/await. CPU → Worker Threads.
  3. Use a pool. One-shot workers for each task don't scale.
  4. Size the pool to CPU count. More threads than cores = context switching overhead.
  5. Handle worker crashes. Workers can throw. Your pool needs to replace crashed workers and not drop the task.
  6. Consider Piscina. It handles all the pool management, backpressure, and TypeScript support out of the box.

If you're doing image processing, PDF generation, cryptography, data parsing at scale, or any computation that pegs a CPU core for more than ~10ms per request, Worker Threads are the right tool. For everything else, they're probably overkill.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read