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.
Updated 8 min read

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?
| Option | Runs | Memory | Best for |
|---|---|---|---|
worker_threads | JavaScript in another thread of the same process | Separate heaps; can transfer ArrayBuffers or share SharedArrayBuffers | CPU-heavy JavaScript called from a server |
child_process | Any program in a new OS process | Fully separate; data passes through pipes or IPC | Running other binaries, strong isolation |
cluster or several containers | Copies of your whole server | Fully separate | Scaling request throughput across cores |
| libuv thread pool | Built-in fs, dns.lookup(), async crypto and zlib | Managed by Node.js | Already 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.

The minimum you need to know about the API
new Worker(file, options)starts a thread from a file path orURL. Options includeworkerData(cloned into the worker),resourceLimitsandname.parentPortinside the worker andworker.postMessage()outside exchange messages. Values are copied with the structured clone algorithm: functions cannot be sent, class instances arrive as plain objects and aBufferarrives as aUint8Array.'error'fires on an uncaught exception in the worker, which then stops;'exit'is always the last event.process.envin a worker is a copy of the parent's environment. Changes in one thread are not visible in the other unless you passenv: 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:
// 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:
// 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:
// 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();$ 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
9227465The 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.

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.
// 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);$ node transfer.mjs
sender length after transfer: 0
worker received bytes: 67108864The 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:
// 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)); // 4000000Shared 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:
$ node oom-worker.mjs
error ERR_WORKER_OUT_OF_MEMORY Worker terminated due to reaching memory limit: JS heap out of memory
exit 1The 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-workerwhen 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
-rpreload scripts, unless you setexecArgv. A preload that starts a worker can spawn workers without end. - Some
processfeatures 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?
clusterruns several copies of your whole server as separate processes to handle more requests.worker_threadsruns extra threads inside one process to offload specific CPU-heavy tasks.Can workers share memory with the main thread?
Yes, through a
SharedArrayBuffer, coordinated withAtomics. You can also transfer anArrayBuffer, 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 withERR_WORKER_OUT_OF_MEMORYand the main process keeps running. Handle the'error'event and start a replacement.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
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.
More from Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone

Industry updateNode.js
Which Node.js version to use in October 2026: LTS lines and the new schedule
Node.js 26 becomes LTS on 28 October 2026, Node.js 24 moves to maintenance and 20.x is already end of life. Here is every supported line, the new yearly release model from Node.js 27, and which version to run.
By Cloudsoft Zone
