Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsDevelopmentJavaScriptArchitecture guide

Node.js worker threads: when they help and how to pool them

When worker threads speed up a Node.js service and when they do not, how they compare with child processes and cluster, and a small tested worker pool.

Cloudsoft Zone

Updated 8 min read

Five old wooden spools of cotton sewing thread in red, black, grey, dark brown and beige lined up on a white background
Photograph: W.carter / CC BY-SA 4.0, via Wikimedia Commons
In this article

Use worker threads when a Node.js service spends real CPU time in JavaScript: image or PDF processing, hashing, parsing large documents, compression in pure JavaScript, or heavy calculations. Do not use them to speed up database queries, HTTP calls or file reads; the official documentation says plainly that workers "do not help much with I/O-intensive work". And never start a worker per request: keep a small, fixed pool and send tasks to it.

This guide explains how worker threads fit into a Node.js architecture, compares them with the other ways to use more than one core, and builds a small, tested worker pool. Everything was checked against the Node.js 24 worker_threads documentation and run on Node.js 24.19.0.

The problem worker threads solve

Node.js runs your JavaScript on one main thread. While a function computes, nothing else in the process runs: no other request handlers, no timers, no health checks. A route that spends 70 ms in a CPU-heavy function caps that process at roughly 14 such requests per second and delays every other request behind it.

A worker thread is a separate V8 instance with its own event loop, running in the same process. Work you post to it runs in parallel with the main thread, which stays free to accept and answer requests.

Workers, child processes, cluster or the thread pool?

OptionRunsMemoryBest for
worker_threadsJavaScript in another thread of the same processSeparate heaps; can transfer ArrayBuffers or share SharedArrayBuffersCPU-heavy JavaScript called from a server
child_processAny program in a new OS processFully separate; data passes through pipes or IPCRunning other binaries, strong isolation
cluster or several containersCopies of your whole serverFully separateScaling request throughput across cores
libuv thread poolBuilt-in fs, dns.lookup(), async crypto and zlibManaged by Node.jsAlready used automatically; sized by UV_THREADPOOL_SIZE

Two practical consequences. If your heavy work is crypto.scrypt() or zlib.gzip(), it already runs off the main thread; use the asynchronous versions and you may not need workers at all. And if you simply want more requests per second from a multi-core machine, running several server processes behind a load balancer is usually simpler than threads.

A woman weaving silk on a wooden hand loom while another woman prepares threads behind her in Ban Tha Sawang, Thailand
Pick the right tool for parallel work: worker threads for CPU-heavy JavaScript, separate processes for isolation and throughput.

The minimum you need to know about the API

  • new Worker(file, options) starts a thread from a file path or URL. Options include workerData (cloned into the worker), resourceLimits and name.
  • parentPort inside the worker and worker.postMessage() outside exchange messages. Values are copied with the structured clone algorithm: functions cannot be sent, class instances arrive as plain objects and a Buffer arrives as a Uint8Array.
  • 'error' fires on an uncaught exception in the worker, which then stops; 'exit' is always the last event.
  • process.env in a worker is a copy of the parent's environment. Changes in one thread are not visible in the other unless you pass env: SHARE_ENV.

Build a pool, not a worker per request

The documentation's advice is direct: for CPU-heavy tasks use a pool of workers, because the overhead of creating workers would likely exceed their benefit. A pool starts a fixed number of workers once, queues tasks when all are busy, and replaces a worker that crashes.

The worker script holds the CPU-heavy function and answers each message:

JavaScript
// fib-worker.mjs
import { parentPort } from 'node:worker_threads';

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.on('message', ({ id, n }) => {
  parentPort.postMessage({ id, result: fib(n) });
});

The pool keeps idle workers, a queue and a map of pending promises:

JavaScript
// pool.mjs
import { Worker } from 'node:worker_threads';
import { availableParallelism } from 'node:os';

export class WorkerPool {
  #idle = [];
  #queue = [];
  #pending = new Map();
  #nextId = 0;
  #workers = new Set();
  #closing = false;

  constructor(file, size = Math.max(1, availableParallelism() - 1)) {
    for (let i = 0; i < size; i++) this.#spawn(file);
  }

  #spawn(file) {
    const worker = new Worker(file, {
      resourceLimits: { maxOldGenerationSizeMb: 128 },
    });
    this.#workers.add(worker);
    worker.on('message', ({ id, result }) => {
      this.#pending.get(id).resolve(result);
      this.#pending.delete(id);
      worker.currentId = undefined;
      this.#release(worker);
    });
    worker.on('error', (err) => {
      this.#workers.delete(worker);
      // the worker is gone: fail its task and start a replacement
      if (worker.currentId !== undefined) {
        this.#pending.get(worker.currentId)?.reject(err);
        this.#pending.delete(worker.currentId);
      }
      if (!this.#closing) this.#spawn(file);
    });
    this.#release(worker);
  }

  #release(worker) {
    const next = this.#queue.shift();
    if (next) {
      worker.currentId = next.id;
      worker.postMessage(next.task);
    } else {
      this.#idle.push(worker);
    }
  }

  async close() {
    this.#closing = true;
    await Promise.all([...this.#workers].map((w) => w.terminate()));
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const id = this.#nextId++;
      this.#pending.set(id, { resolve, reject });
      const task = { id, ...data };
      const worker = this.#idle.pop();
      if (worker) {
        worker.currentId = id;
        worker.postMessage(task);
      } else {
        this.#queue.push({ id, task });
      }
    });
  }
}

Using it from application code looks like any other async function:

JavaScript
// main.mjs
import { WorkerPool } from './pool.mjs';

const pool = new WorkerPool(new URL('./fib-worker.mjs', import.meta.url), 4);

console.time('8 jobs on 4 workers');
const results = await Promise.all(
  Array.from({ length: 8 }, () => pool.run({ n: 35 })),
);
console.timeEnd('8 jobs on 4 workers');
console.log(results[0]);
await pool.close();
Shell
$ node serial.mjs   # the same 8 jobs on the main thread
8 jobs on the main thread: 550.378ms

$ node main.mjs
8 jobs on 4 workers: 185.665ms
9227465

The speed-up is about three times on an 8-core laptop, not four, because starting workers and passing messages costs something. Your numbers will differ; the shape will not. More importantly, while those jobs ran, the main thread was free to serve other requests.

We also tested failure: a worker that throws on a bad input rejects only that task, the pool starts a replacement, and the next task succeeds. In production, add a per-task timeout and a maximum queue length so a burst of work fails fast instead of growing memory without limit. If you prefer not to maintain this code, established pool libraries such as Piscina implement the same pattern with more features.

How many workers?

os.availableParallelism() returns the number of CPUs the process can use. A common starting point is that number minus one, leaving a core for the main thread, then adjust from measurements. In containers, set the pool size explicitly from configuration, because CPU limits may be lower than what the machine reports.

Black-and-white photo of a long aircraft assembly line in 1943, with workers on numbered fuselages under rows of ceiling lights
A worker pool is a fixed crew that takes the next task from the queue, rather than hiring a new worker for each job.

Moving large data without copying it

Posting a 64 MiB buffer copies it unless you transfer it. Transferring moves ownership: the receiver gets the memory and the sender's view becomes empty.

JavaScript
// transfer.mjs
import { Worker } from 'node:worker_threads';

const code = `
const { parentPort } = require('node:worker_threads');
parentPort.once('message', (bytes) => {
  parentPort.postMessage(bytes.byteLength);
});
`;

await using worker = new Worker(code, { eval: true }); // terminated when the scope ends
const bytes = new Uint8Array(64 * 1024 * 1024); // 64 MiB

worker.postMessage(bytes, [bytes.buffer]); // move, do not copy
console.log('sender length after transfer:', bytes.byteLength);

const [received] = await new Promise((resolve) =>
  worker.once('message', (...args) => resolve(args)),
);
console.log('worker received bytes:', received);
Shell
$ node transfer.mjs
sender length after transfer: 0
worker received bytes: 67108864

The await using line relies on worker[Symbol.asyncDispose](), added in Node.js 24.2.0, which calls terminate() when the block exits. One caution from the documentation: buffers created with Buffer.from() or Buffer.allocUnsafe() may come from Node.js's internal pool and are always cloned, so allocate data you plan to transfer with new Uint8Array() or Buffer.alloc().

Sharing memory

A SharedArrayBuffer is visible to every thread you pass it to. Coordinate writes with Atomics, or threads will overwrite each other's updates:

JavaScript
// shared.mjs
import { Worker } from 'node:worker_threads';

const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);

const code = `
const { workerData } = require('node:worker_threads');
const counter = new Int32Array(workerData);
for (let i = 0; i < 1_000_000; i++) Atomics.add(counter, 0, 1);
`;

const workers = Array.from({ length: 4 }, () =>
  new Worker(code, { eval: true, workerData: shared }),
);
await Promise.all(workers.map((w) => new Promise((r) => w.on('exit', r))));
console.log(Atomics.load(counter, 0)); // 4000000

Shared memory is powerful and easy to get wrong. Most services should stick to messages and transfers.

Limit what a worker can consume

The resourceLimits option caps a worker's JavaScript heap with maxOldGenerationSizeMb, maxYoungGenerationSizeMb, codeRangeSizeMb and stackSizeMb. A worker that exceeds its limit is terminated rather than taking the whole process down:

Shell
$ node oom-worker.mjs
error ERR_WORKER_OUT_OF_MEMORY Worker terminated due to reaching memory limit: JS heap out of memory
exit 1

The limits cover only the JavaScript engine heap, not external memory such as ArrayBuffer contents, so still bound the size of inputs you accept.

Gotchas

  • Workers are not a sandbox. They share the process, file system access and, by default, a copy of every environment variable. Do not run untrusted code in them.
  • The Permission Model blocks workers unless you add --allow-worker when running with --permission, and the documentation notes that the model is not inherited by the worker threads you allow.
  • Preload scripts are inherited. Workers inherit the parent's CLI flags, including -r preload scripts, unless you set execArgv. A preload that starts a worker can spawn workers without end.
  • Some process features differ in a worker: process.exit() stops only that thread, and signals are not delivered to it.
  • Cloning is not free. Sending a large object graph each time can cost more than the work itself. Send IDs or transfer buffers instead.

What to do next

Profile first: run the slow endpoint under node --cpu-prof and confirm that JavaScript CPU time, not waiting on I/O, is the bottleneck. If it is, move only that function into a worker file, put a pool in front of it with a fixed size and a queue limit, and measure event loop delay before and after. If the profile shows waiting on the database or network, workers will not help; fix the query or add caching instead.

Frequently asked questions

  • Do worker threads make database queries or HTTP calls faster?

    No. That work is I/O and Node.js already handles it without blocking the main thread. The documentation says workers do not help much with I/O-intensive work. Use them for CPU-heavy JavaScript.

  • Should I create a new worker for every request?

    No. Starting a worker costs time and memory, and the documentation recommends a pool of workers. Start a fixed number once and queue tasks to them.

  • How many worker threads should a pool have?

    Start with os.availableParallelism() minus one and adjust from measurements. In containers, configure the size explicitly to match the CPU limit.

  • What is the difference between worker_threads and cluster?

    cluster runs several copies of your whole server as separate processes to handle more requests. worker_threads runs extra threads inside one process to offload specific CPU-heavy tasks.

  • Can workers share memory with the main thread?

    Yes, through a SharedArrayBuffer, coordinated with Atomics. You can also transfer an ArrayBuffer, which moves it to the other thread without copying.

  • What happens if a worker runs out of memory?

    If you set resourceLimits, the worker is terminated with ERR_WORKER_OUT_OF_MEMORY and the main process keeps running. Handle the 'error' event and start a replacement.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Worker threads (Node.js v24 docs) — Node.js
  2. Don't Block the Event Loop (or the Worker Pool) — Node.js
  3. Command-line options: UV_THREADPOOL_SIZE (Node.js v24 docs) — Node.js
  4. Permissions (Node.js v24 docs) — Node.js

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.