Is your API work actually CPU-bound?
Worker threads exist for one problem: JavaScript that runs synchronously long enough to stall the event loop. While a handler is hashing a password, resizing an image, parsing a large JSON payload or running a scoring model, no other request on that process makes progress. If that describes your hot paths, workers can help. If it doesn't, they mostly add cost.
Measure before you move code
Start with event-loop delay. monitorEventLoopDelay from perf_hooks returns a histogram that records how late the loop's timer fires, in nanoseconds. Watch the p99 and max alongside request latency. A p99 delay in the tens of milliseconds that rises with traffic means something is holding the thread. Delay that stays flat while latency climbs points elsewhere, usually at a database, a downstream service or connection pool limits.
Then find the culprit. Run the service under load with node --cpu-prof. It writes a .cpuprofile file you can open in Chrome DevTools. Look for wide, self-time-heavy frames in your own code or in a dependency: regex backtracking, serialization, compression, crypto, template rendering.
When workers don't help
I/O-bound handlers already wait off-thread. Moving a database call into a worker doesn't make the database faster. It adds message serialization in both directions, plus a separate V8 isolate with its own heap for each worker. You pay that overhead and memory, and latency stays the same.
Use workers when profiles show sustained synchronous CPU work per request. Skip them when event-loop delay is low or the time is spent waiting on I/O.
// Run under load: node --cpu-prof server.js, then Ctrl+C to write the .cpuprofile
const http = require('node:http');
const crypto = require('node:crypto');
const { monitorEventLoopDelay } = require('node:perf_hooks');
// Sample event-loop delay every 20 ms. The histogram records nanoseconds.
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
const ms = (ns) => (ns / 1e6).toFixed(1);
// Log every 5 s, then reset so each window reflects current traffic.
setInterval(() => {
console.log(
`loop delay p50=${ms(delay.percentile(50))}ms ` +
`p99=${ms(delay.percentile(99))}ms max=${ms(delay.max)}ms`
);
delay.reset();
}, 5000).unref();
http.createServer((req, res) => {
if (req.url === '/cpu') {
// Synchronous CPU work: the thread is busy, so p99 delay climbs with load.
const key = crypto.pbkdf2Sync('placeholder-password', 'example-salt', 200000, 64, 'sha512');
res.end(key.toString('hex').slice(0, 16));
} else {
// Simulated I/O wait: request latency rises, but loop delay stays flat.
setTimeout(() => res.end('ok'), 50);
}
}).listen(3000);
// Exit normally on Ctrl+C so --cpu-prof can flush the profile to disk.
process.on('SIGINT', () => process.exit(0));
A minimal server that logs event-loop delay percentiles from monitorEventLoopDelay, contrasting a CPU-bound route that drives delay up with an I/O-style route that adds latency without blocking the loop, and exits cleanly so --cpu-prof can write its profile.
Who should use worker threads
Worker threads fit teams running Node.js APIs that meet all of these conditions:
- Profiles show synchronous JavaScript hotspots on request paths. Examples include parsing or validating large documents, rendering templates, image or PDF manipulation written in JavaScript or WebAssembly, scoring or rules evaluation, and compression or hashing done through synchronous APIs.
- The caller needs the result in the same response. If the result can arrive later, a background job queue is usually simpler.
- The process has more than one CPU's worth of quota. Workers give real parallelism only when there are cores for them to run on. With a single CPU, they keep the event loop responsive by letting the OS scheduler interleave threads, but they don't add throughput.
- The team wants to stay in one runtime and one deployment. Workers let you reuse existing JavaScript modules, tests and tooling instead of standing up a new service.
They also suit library and platform authors who need to offer a CPU-heavy function without forcing every consumer to block their event loop.
When to avoid worker threads
Avoid workers, or reach for something else first, in these cases:
- The work is I/O-bound. Database queries, HTTP calls and file reads already happen off the main thread. A worker adds serialization and memory without reducing wait time.
- A native async API already exists.
crypto.pbkdf2,crypto.scrypt, asynczlibandfsfunctions already run on the libuv thread pool. Switching frompbkdf2Synctopbkdf2is a smaller change than adding a worker. You may still need to adjustUV_THREADPOOL_SIZEif those calls queue behind each other. - Tasks are small but payloads are large. If cloning the input and output costs as much as the computation, offloading makes latency worse and still blocks the main thread during serialization. The data transfer section below works through this.
- You need to isolate untrusted code. Workers share the process. They are not a security sandbox, and a native crash in one of them takes down the whole process. Use a child process or a separate service.
- Memory is the binding constraint. Each worker carries its own heap and module copies. In a tightly limited container, a pool can turn a latency problem into OOM kills.
- The result isn't needed synchronously. Emails, reports, thumbnails and exports belong in a job queue, where retries and backpressure are handled outside the request path.
- The hot loop is better fixed in place. Sometimes the profile points at a pathological regex, an accidental quadratic loop or repeated serialization. Fixing that is cheaper than offloading it.
How worker threads work
The node:worker_threads module starts an OS thread inside the same process. That thread gets its own V8 isolate, its own Node environment and its own event loop. You create one with new Worker(filename, { workerData }). The worker script runs from the top, loads its own copies of any modules it requires or imports, and then waits for messages.
The two sides share no JavaScript objects. They communicate through message ports. In the worker, parentPort.postMessage() sends to the main thread and parentPort.on('message', ...) receives. On the main thread, the Worker instance exposes the same postMessage and message event. MessageChannel creates extra port pairs if you need direct channels. Messages are copied with the structured clone algorithm unless you transfer an ArrayBuffer or share a SharedArrayBuffer, which the data transfer section covers.
Because each worker has its own event loop, a worker can run a long synchronous computation without delaying timers, sockets or promise callbacks on the main thread. The main thread only pays for posting the message and handling the reply. The worker emits error on an uncaught exception and exit when it stops. Native addons loaded in workers must be built to support multiple contexts.
A pool builds on these primitives. It keeps a set of workers alive, attaches a task identifier to each message, and matches results back to the pending promise for that task.
Worker threads vs the libuv thread pool, child processes and cluster
Node gives you four ways to run work off the main event loop. They differ in what they isolate and how data moves between them.
| Mechanism | Isolation | Memory model | Runs your JavaScript? |
|---|---|---|---|
| libuv thread pool | Native threads, no V8 | Shared native buffers | No |
| Worker threads | Separate V8 isolate and event loop, same process | Structured clone, transferables, SharedArrayBuffer |
Yes |
child_process |
Separate OS process | Serialized IPC messages | Yes |
cluster |
Separate OS processes sharing a listening port | Nothing shared | Yes |
The libuv pool handles native work such as fs, dns.lookup, async crypto functions like pbkdf2 and scrypt, and async zlib. You cannot submit JavaScript to it. Its default size is four threads, so a burst of password hashing can queue behind file reads.
Worker threads are the only option that runs JavaScript in parallel inside one process. A worker that crashes or leaks memory affects the whole process, but it starts faster and uses less memory than a child process. Child processes give you a hard boundary, which suits untrusted or unstable code. Cluster adds throughput across cores, but a slow request still blocks the event loop of whichever process accepted it.
You can also move the work out of Node. A native module removes interpreter overhead from tight loops. A separate service in another language fits a hot path that will grow. A background job queue fits any work whose result the caller does not need in the same response.
How a worker thread pool works and why per-request workers fail
If CPU work stays in Node, the obvious first version creates a new Worker() inside the request handler and terminates it when the result comes back. That fails for three reasons.
- Startup cost. Each worker boots a fresh V8 isolate and Node environment, then loads and compiles your modules again. On a request path, that cost is added to every call and can be larger than the task itself.
- Memory per isolate. Each worker has its own heap and its own copy of every loaded module. Memory grows with the number of in-flight requests, not with how much work there is.
- Unbounded concurrency. A burst of 500 requests becomes 500 threads competing for a few cores. Throughput falls as context switching rises, and the container can be OOM-killed before any request finishes.
The baseline fix is a long-lived pool. You start a fixed set of workers once, at boot, and keep them warm. Incoming tasks go into a queue, and each idle worker takes the next one. Workers communicate with the main thread only through messages. The main event loop stays responsive because all it does is enqueue tasks, post messages and resolve promises.
HTTP request
│
▼
┌──────────────┐ enqueue ┌────────────┐ postMessage ┌──────────┐
│ main thread │──────────▶│ task queue │──────────────▶│ worker 1 │
│ (event loop) │ │ [t4,t5,t6] │──────────────▶│ worker 2 │
│ │◀──────────┴────────────┴───────────────│ worker N │
└──────────────┘ result message └──────────┘
│ resolve promise
▼
HTTP response
The queue absorbs bursts, and the fixed worker count caps both CPU contention and memory.
HTTP request (burst)
│
▼
┌────────────────┐ 1. enqueue ┌─────────────────┐
│ main thread │──────────────▶│ task queue │
│ (event loop) │ │ [t4] [t5] [t6] │
└───┬────────▲───┘ └────────┬────────┘
│ │ │ 2. postMessage to
│ │ │ next idle worker
│ │ ▼
│ │ ┌──────────────────────────────────────────────────┐
│ │ │ worker pool (fixed size N, started at boot) │
│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ worker 1 │ │ worker 2 │ ... │ worker N │ │
│ │ │ │ own heap │ │ own heap │ │ own heap │ │
│ │ │ └──────────┘ └──────────┘ └──────────┘ │
│ │ └───────────┬──────────────────────────────────────┘
│ │ │ 3. result message
│ └───────────────┘
│ 4. resolve promise
▼
HTTP response
The main event loop only enqueues tasks, posts messages to a fixed pool of warm workers, and resolves promises when result messages return, so the queue absorbs bursts while the worker count caps CPU contention and memory.
Implementing a bounded pool with Piscina
The handler stays thin. It parses and validates the request on the main thread, calls pool.run(payload, { signal }), awaits the result and serialises the response. The worker module exports a single function that receives the payload and returns a value. Everything about concurrency lives in the pool's constructor options.
| Option | What it controls | How to reason about it |
|---|---|---|
minThreads |
Workers kept alive when idle | Set it to the steady-state need so warm-up (module load, JIT) doesn't land on the first requests after a lull |
maxThreads |
Upper bound on concurrent workers | Start at cores available to the container minus one for the event loop. More threads than cores adds context switching, not throughput |
maxQueue |
Tasks waiting for a free worker | Set it explicitly. A long queue only turns overload into latency |
idleTimeout |
How long a surplus worker idles before exiting | Longer keeps warm state; shorter frees memory |
When the queue is at its limit, run rejects. Catch that rejection and return 503 with Retry-After, so the load balancer or client backs off instead of waiting.
For timeouts, pass AbortSignal.timeout(ms), or a controller tied to the client disconnecting. Cancellation has limits. A task still in the queue is simply dropped. A task already running a synchronous loop cannot see the signal. Piscina can only stop it by terminating the worker, which discards that worker's warm state and forces a cold replacement.
Piscina saves you from writing a scheduler, but its defaults were not chosen for your container's CPU quota. Read them and set them explicitly.
import http from 'node:http';
import os from 'node:os';
import Piscina from 'piscina';
// Leave one core for the event loop. availableParallelism() may report host cores,
// not the container's CPU quota, so check it against your limit.
const cores = Math.max(1, os.availableParallelism() - 1);
const MAX_QUEUE = cores * 4; // short queue: overload becomes fast 503s, not latency
const pool = new Piscina({
filename: new URL('./hash-worker.mjs', import.meta.url).href, // exports default (payload) => result
minThreads: Math.max(1, Math.floor(cores / 2)), // steady-state need, kept warm through lulls
maxThreads: cores, // more threads than cores only adds context switches
maxQueue: MAX_QUEUE, // set explicitly, never rely on the default
idleTimeout: 60_000, // ms a surplus worker idles before exiting
});
http.createServer(async (req, res) => {
if (pool.queueSize >= MAX_QUEUE) {
// run() would reject here anyway; fail fast and tell callers to back off
res.writeHead(503, { 'Retry-After': '2' }).end();
return;
}
const input = new URL(req.url, '').searchParams.get('input') ?? '';
// Abort on timeout or client disconnect. A queued task is dropped cleanly;
// a running synchronous task can only be stopped by terminating its worker.
const disconnect = new AbortController();
res.on('close', () => { if (!res.writableFinished) disconnect.abort(); });
const signal = AbortSignal.any([AbortSignal.timeout(2_000), disconnect.signal]);
try {
const result = await pool.run({ input }, { signal });
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(result));
} catch (err) {
if (res.destroyed) return; // client already gone
res.writeHead(err.name === 'AbortError' ? 504 : 500).end();
}
}).listen(3000);
A thin HTTP handler that offloads work to a Piscina pool sized from available cores, returns 503 with Retry-After when the queue is full, and passes an AbortSignal that combines a timeout with client disconnect.
Data transfer costs: cloning, transferables and SharedArrayBuffer
Every postMessage call, including the one Piscina makes for each task, uses the structured clone algorithm by default. The sender walks the object graph and serializes it, and the worker deserializes it into its own heap. Both steps run synchronously, and the serialization step blocks the main event loop you were trying to protect. Nested objects and long arrays of small records cost the most, because every property is visited.
| Mechanism | What crosses the boundary | Sender after send | Main risk |
|---|---|---|---|
| Structured clone | A deep copy | Keeps its copy | CPU and memory cost scale with payload size |
Transferable ArrayBuffer |
Ownership of the bytes | Buffer is detached (byteLength 0) |
Using a detached buffer by mistake |
SharedArrayBuffer + Atomics |
Access to the same memory | Both sides read and write it | Data races and torn reads |
A transfer looks like this: port.postMessage({ pixels }, [pixels.buffer]). Note that the list names the typed array's underlying buffer, not the view itself. Piscina exposes the same idea through Piscina.move().
To decide whether offloading pays, compare the two paths. Offloading is worth it when:
T_inline > T_clone_in + T_clone_out + T_queue
Illustrative estimate (assumed numbers, not a benchmark):
- Assumed inputs: a 10 MB object graph, a clone cost of 2 ms per MB, a 1 MB result, and 15 ms of compute.
- Clone in: 10 × 2 = 20 ms.
- Clone out: 1 × 2 = 2 ms.
- Total overhead: 22 ms before any queueing, against 15 ms of compute.
In this case the worker loses. On top of that, the 20 ms clone-in still blocks the main thread. For small tasks with large payloads, either parse the input inside the worker from raw bytes or keep the work on the main thread.
// transfer-demo.js: run with `node transfer-demo.js`
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
const { performance } = require('node:perf_hooks');
if (isMainThread) {
// Measure clone cost on your real payload shape before deciding to offload.
const records = Array.from({ length: 200_000 }, (_, i) => ({ id: i, tag: 'row' }));
const t0 = performance.now();
structuredClone(records); // roughly what postMessage does on the main thread
console.log(`clone of records: ${(performance.now() - t0).toFixed(1)} ms`);
const worker = new Worker(__filename);
const pixels = new Uint8Array(1920 * 1080 * 4); // ~8 MB of raw bytes
pixels.fill(128);
worker.on('message', (result) => {
console.log('first byte back from worker:', result[0]); // 127
worker.terminate();
});
// The transfer list names the underlying ArrayBuffer, not the view.
worker.postMessage({ pixels }, [pixels.buffer]);
// Ownership moved, so the sender's view is now detached.
console.log('sender byteLength after transfer:', pixels.byteLength); // 0
} else {
parentPort.on('message', ({ pixels }) => {
for (let i = 0; i < pixels.length; i++) pixels[i] = 255 - pixels[i];
// Transfer the result back as well, so no copy is made in either direction.
parentPort.postMessage(pixels, [pixels.buffer]);
});
}
The example times a structured clone of a record array to estimate main-thread serialization cost, then transfers a large pixel buffer to a worker and back without copying. The sender's view is detached after the transfer, so its byteLength reads 0.
Sizing for containers, backpressure and failure handling
Size the pool from the CPU you are actually allowed to use, not from what the host reports. On a 64-core node running a container with a two-CPU quota, a pool sized from os.cpus().length starts 64 threads that compete for two CPUs' worth of time. Every task gets slower and the quota throttling adds tail latency. Where os.availableParallelism() is available, prefer it, but it may reflect CPU affinity rather than a CFS quota. Depending on your runtime, you may need to read the cgroup limit yourself or pass an explicit size through configuration. Then subtract headroom. The main thread still parses requests, the libuv pool handles crypto and filesystem work, and V8 runs GC threads. A reasonable starting point is the quota minus one. If you run several Node processes per node, divide the quota among them.
Memory needs the same discipline. Each worker has its own V8 heap. Four workers on a 512 MB container can push you from a latency problem into an OOM kill, which takes down every in-flight request. Set resourceLimits per worker so a runaway task kills only its worker with ERR_WORKER_OUT_OF_MEMORY.
Treat worker death as routine. An uncaught exception inside a worker surfaces as an error event and the worker exits. Piscina rejects that task's promise and replaces the thread. Make sure callers map the rejection to a 5xx response rather than a hang.
Finally, bound the queue. An unbounded queue turns overload into silently growing latency and memory use. Set maxQueue and decide explicitly what a full queue means. Rejecting with a 503 and Retry-After sheds load. Queueing longer only makes sense if the clients can tolerate the wait.
Observability, graceful shutdown and the cost of getting it right
Record two durations per task: queue wait (submitted to started) and execution time (started to finished). A p99 regression is diagnosable only when you know which one moved. Also track pool utilization (busy workers ÷ total workers) and event-loop delay on the main thread, using monitorEventLoopDelay from perf_hooks. If loop delay climbs while worker execution time stays flat, something CPU-heavy is still running on the main thread.
Illustrative estimate (assumed numbers, not a benchmark). Assume tasks take S = 40 ms, the pool has c = 4 workers, and requests arrive at λ per second. Utilization is ρ = λ·S / c, so capacity is 4 / 0.04 = 100 tasks/s. A rough single-queue approximation for wait is W ≈ S·ρ / (1 − ρ). Multi-worker pools wait somewhat less at moderate load, but the curve has the same shape near saturation.
- λ = 50/s: ρ = 0.5, W ≈ 40 ms
- λ = 80/s: ρ = 0.8, W ≈ 160 ms
- λ = 95/s: ρ = 0.95, W ≈ 760 ms
At 95% utilization the work itself still takes 40 ms, but queueing dominates latency. Alert on utilization well before it reaches 1.
Graceful shutdown during deploys. On SIGTERM, follow these steps in order:
- Fail readiness checks and stop accepting new requests.
- Wait for queued and in-flight tasks to drain, with a deadline shorter than the orchestrator's kill timeout.
- Destroy the pool.
If you terminate workers first, every in-flight request fails mid-computation.
The cost of getting it right. Workers cost more than an async function. You take on a separate module, serialization boundaries, sizing decisions and shutdown logic. Offloading to a separate service costs more again, because you add a network hop and another deployment. The payoff of a bounded, measured pool comes during latency incidents. Instead of guessing, you can read queue wait, execution time and loop delay and see which one moved.
How worker threads can save engineering time
The time savings come from avoiding larger changes and from shorter investigations. Faster code alone doesn't account for them.
- No new service to build and run. Splitting a CPU hotspot into its own service means a new deployment, a network contract, retries, authentication between services, and separate on-call ownership. A worker pool keeps the code in the same repository, runtime and release process.
- Existing code moves with little rewriting. A pure function that takes a payload and returns a result can usually become a worker module as is. Its unit tests still run against the function directly, without the pool.
- A pool library replaces custom scheduling code. Queueing, worker reuse, replacement after crashes and cancellation are the parts teams tend to get wrong when they write them from scratch. Using a maintained pool and setting its options explicitly leaves only the sizing and failure policy to decide.
- Incidents are faster to diagnose. With queue wait, execution time, pool utilization and main-thread loop delay recorded, the question "why did p99 jump?" has a direct answer: more traffic, slower tasks, or CPU work leaking back onto the main thread. Without those signals, the same question often turns into a round of profiling under production-like load.
- Overload behaviour is decided once. A bounded queue with a 503 and
Retry-Aftergives a predictable failure mode. That avoids repeated debugging of cascading timeouts in callers.
These savings depend on the earlier steps. If you skip the measurement, you can spend that time on a worker pool that doesn't fix an I/O-bound latency problem.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to node.js worker threads, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.