129: Worker Threads, Child Processes, Cluster, IPC, and CPU-Bound Work
Learning objectives
You will learn to:
- recognize CPU-bound work that blocks Node's main JavaScript thread;
- use
worker_threads; - understand structured cloning and transferable data;
- use worker pools rather than spawning one worker per tiny task;
- use
child_processsafely; - distinguish
spawn,exec,execFile, andfork; - avoid command injection;
- understand IPC;
- understand the role and limits of
cluster; - choose between workers, processes, queues, and separate services;
- shut down parallel work correctly.
The problem
Consider an HTTP route that performs an expensive calculation:
app.get('/hash', (req, res) => {
const result = veryExpensivePureJavaScript();
res.json({ result });
});
If the calculation takes two seconds, the main JavaScript thread is occupied for those two seconds. It cannot run the ordinary callbacks that would accept and process other requests, complete timers, or continue other JavaScript work. The problem is not specific to this route; any sufficiently long synchronous computation has the same effect.
Adding async and await does not move the calculation to another thread:
app.get('/hash', async (req, res) => {
const result = await Promise.resolve(
veryExpensivePureJavaScript(),
);
res.json({ result });
});
The call to veryExpensivePureJavaScript() happens before Promise.resolve receives its value. It still runs synchronously on the same thread, so the event loop remains blocked until the function returns. A promise can represent asynchronous completion, but wrapping synchronous CPU work in a promise does not make that work parallel.
Options
The right solution depends on what the work needs. Start by asking whether the work can be removed or made cheaper, then choose the execution boundary that provides the isolation and durability the workload actually requires.
Optimize/remove work
This is the best option when it is available. Avoiding unnecessary computation, choosing a better algorithm, caching a safe result, or reducing the input can help every request without introducing another execution model.
Worker thread
A worker thread runs JavaScript in parallel within the same process. It is useful for CPU-heavy JavaScript when process-level isolation is not required.
Child process
A child process has separate process memory and a separate operating-system process boundary. That gives stronger isolation than a worker thread, at the cost of more overhead and a different communication model.
External job queue/service
An external queue or service is appropriate for durable, horizontally scalable background work. Jobs can survive an API process restart, and the worker capacity can be scaled independently.
Choose among these options based on isolation, durability, CPU demand, dependencies, and deployment. A worker thread is not automatically the best answer just because the code is written in JavaScript.
Worker threads
The basic worker-thread arrangement has two sides. The worker receives input, performs the calculation, and sends a result back to its parent:
// worker.js
import {
parentPort,
workerData,
} from 'node:worker_threads';
const result = expensive(workerData);
parentPort.postMessage({
result,
});
The parent can create that worker and turn its message or failure into a promise:
import { Worker } from 'node:worker_threads';
function runWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{
workerData: data,
},
);
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(
new Error(`Worker exited with code ${code}`),
);
}
});
});
}
This example is meant to show the mechanics: construct a worker, pass initial data, receive a message, and handle errors and non-zero exits. A production wrapper also needs a clear policy for cancellation, timeouts, duplicate completion signals, and worker replacement.
Do not create a fresh worker for every tiny request in production. Worker startup and teardown have overhead, and that overhead can exceed the time spent doing the small task. Reuse workers for repeated work instead.
Worker pool
For repeated CPU-bound jobs, keep a bounded set of workers alive and dispatch jobs to them. A useful mental model is:
HTTP requests
↓
bounded job queue
↓
N persistent worker threads
↓
results
The queue must be bounded or otherwise governed by backpressure. A pool that accepts unlimited work merely moves the memory and latency problem from the event loop into the queue.
Pool size should be based on several constraints:
- available CPU;
- other application work;
- container quota;
- workload;
- memory.
Do not simply use os.cpus().length without considering the environment. A container may have a CPU quota smaller than the host's CPU count, and the API process still needs capacity for networking and ordinary application code. Too many workers can cause contention rather than useful parallelism.
Use a mature worker-pool package when it fits the project. A pool has more responsibilities than starting threads: it must distribute jobs, cap pending work, report failures, handle worker exits, and shut down cleanly.
Worker communication
Messages between workers and their parent use structured-clone semantics. Values are serialized according to the structured-clone rules rather than sharing ordinary JavaScript object identity. That is convenient, but copying a large payload can be expensive in both time and memory.
Transferable objects provide another option. For ArrayBuffer-like memory, ownership can be transferred instead of copied:
worker.postMessage(buffer, [buffer.buffer]);
After the transfer, the original view can become detached and unusable. This is an ownership change, not a performance hint that leaves both sides with independent usable copies. Code on the sending side must stop assuming it can continue reading or writing the transferred memory.
SharedArrayBuffer
Workers can share memory with SharedArrayBuffer and coordinate access with Atomics. This can avoid some copying, but it introduces genuine shared-memory concurrency problems:
- races;
- synchronization;
- atomics;
- deadlocks/livelock patterns.
Do not use shared memory unless the performance requirement is real and the team can reason about the concurrency protocol. Message passing is usually easier to test, observe, and maintain. Shared memory should solve a measured bottleneck, not compensate for an unclear design.
Worker environment
Workers have:
- a separate JS isolate;
- their own event loop;
- shared process resources in some ways;
- access to worker APIs.
The separate isolate means ordinary JavaScript objects are not simply shared between the parent and worker. However, a worker is not a separate operating-system process. Workers still live within the parent process's failure and resource boundary.
A fatal process crash can affect all workers. For stronger isolation, use child processes or separate services. This distinction matters when the work involves risky native dependencies, needs independent resource limits, or must remain available if the API process fails.
Child process
Node can launch programs as child processes. Child-process APIs differ mainly in how they start the command, handle its arguments, expose its output, and communicate with it. The distinction is especially important when input is untrusted.
spawn
spawn starts a process and exposes its streams so output can be consumed as it arrives:
import { spawn } from 'node:child_process';
const child = spawn(
'git',
['status', '--short'],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
);
for await (const chunk of child.stdout) {
process.stdout.write(chunk);
}
The command and its arguments are supplied separately. spawn streams output and accepts an argument array, making it a good fit for commands that may produce substantial output or run for a while. You still need to consume or otherwise manage both output streams and handle process errors.
exec
exec accepts a command string:
exec('git status --short', ...);
It runs the command through a shell and buffers the output before invoking the callback. That is convenient for a short, trusted command where the complete result is small.
The shell is also the important security boundary here. Interpolating untrusted input into the command string is dangerous:
exec(`convert ${filename}`);
If filename contains shell syntax, the shell may interpret it as additional commands or options. This is command injection. Avoid exec for user-controlled values; do not treat escaping a string casually as a substitute for a safe argument model.
execFile
execFile runs an executable directly without a shell by default:
execFile(
'git',
['status', '--short'],
callback,
);
For a fixed executable and validated arguments, this is safer than interpolating values into a shell command. It is not a replacement for validation: still validate arguments and file paths, restrict allowed operations, and make sure a value cannot be interpreted as an unintended option or access an unintended file.
fork
fork is specialized for launching another Node module and providing it with an IPC channel:
import { fork } from 'node:child_process';
const child = fork('./child.js');
child.send({
type: 'job',
payload: ...
});
The child receives messages through the process object:
process.on('message', (message) => {
...
});
fork is useful when you want Node-process isolation and structured communication between a Node parent and a Node child. It is not the general API for launching arbitrary executables; use spawn or execFile for that.
Exit and error events
Child-process lifecycle events are easy to conflate:
spawn/error
stdout/stderr
exit
close
Read the Node documentation for the exact distinction between exit and close. In particular, process exit and stream closure are related but not identical observations. Code that needs to know whether all output has been closed must not assume that an exit event alone provides that answer.
Always handle the failure cases that matter to the operation:
- spawn failure;
- non-zero exit;
- timeout/cancellation;
- output limits.
This is where people usually get surprised during debugging: a command can start successfully and still fail later, produce an error on stderr, exceed its output budget, or remain alive after the request that started it has gone away.
Buffered output limits
exec and execFile have maximum buffer limits. A command that produces more output than the configured limit can fail even if the executable itself succeeds.
For huge or unbounded output, use spawn and stream the data. Streaming avoids waiting for the complete output in memory, but it does not remove the need to impose sensible limits or to decide what should happen when a consumer is slower than the child.
Do not buffer untrusted command output indefinitely. An attacker who can influence either the command or its input may be able to turn output volume into a memory-exhaustion problem.
Timeouts and cancellation
When supported by the relevant Node API, connect a child process to an AbortSignal:
const child = spawn(..., {
signal: controller.signal,
});
Cancellation needs an operational policy as well as an API call. Decide what a timeout means for the job, whether partial output is useful, and how the parent reports cancellation to its caller.
Also design kill escalation:
request graceful termination
wait
force kill if deadline
Platform semantics differ. Signals and forceful termination do not behave identically on every operating system, and a child may have spawned descendants. Test the shutdown behavior on the platforms and deployment environments you support.
Zombie/orphan processes
If the parent exits unexpectedly, the child's behavior depends on the platform and the options used to launch it. A child that outlives its request or parent can consume CPU, hold files, or continue processing data after the application believes the work has stopped.
Production supervisors and containers need process-tree-aware shutdown. Do not spawn background children and forget them. Track their ownership, establish a cleanup path, and make shutdown behavior explicit.
Cluster
Node's cluster module can create multiple processes while sharing server-port behavior.
Conceptually:
one primary
→ several worker processes
→ connections distributed
Because the workers are separate processes, this model historically provided a way to use multiple CPU cores for a server.
Modern production deployments often use multiple independent Node processes or containers behind a load balancer or process manager instead. That model can be easier to operate because deployment, scaling, health checks, and failures are explicit at the process or container level.
Know cluster because existing systems use it and Node's roadmap includes it. Do not assume that a new service requires it. Compare it with the process model already provided by the deployment platform.
Shared-nothing processes
Separate processes do not share ordinary JavaScript memory. For example:
const sessions = new Map();
That map exists only inside the process that created it; other processes cannot see its entries.
If requests are load-balanced across workers, any session, rate-limit, or cache state that requires global consistency needs an external or otherwise shared store, or a deliberate sticky-session/session architecture. A local map may appear to work while traffic stays on one process and then fail as soon as another process handles the next request.
PM2
PM2 can manage multiple Node processes, restart them, collect logs, and provide clustering features. Container orchestrators and systemd can also supervise processes.
Do not stack several supervisors without understanding who owns restarts and signals. If both PM2 and an orchestrator believe they should restart the same process, shutdown and failure behavior can become difficult to reason about. Choose a clear ownership model.
Background jobs
For durable jobs such as these:
image processing
email
invoice generation
report export
a queue can be a better boundary than a worker thread inside the API process. The queue lets the application acknowledge work separately from completing it and provides a place to retain jobs while workers or the API are unavailable.
Useful queue capabilities include:
- retry;
- persistence;
- backoff;
- concurrency control;
- independent scaling;
- dead-letter behavior.
A worker thread alone is not durable. If its process exits, in-memory jobs and their state can disappear unless the application has stored them elsewhere.
CPU service architecture
When image processing dominates CPU and memory, a separate service often gives a cleaner resource boundary:
API Node service
→ queue
→ image worker service
→ object storage
This is often better than putting the work inside the API's worker pool. The API can remain focused on request handling, while the image worker receives its own limits, scaling policy, and failure handling. Separate resource limits also prevent a CPU or memory spike in image processing from taking the API down with it.
Native addons
Some packages perform CPU-intensive work in native code and may already use a thread pool or worker internals. Adding another layer of parallelism without measuring can oversubscribe the machine and make performance worse.
Profile first. Understand where the package spends CPU and how it schedules work before introducing a worker pool around it.
Worker error propagation
Worker failures should be represented in a form the parent can classify and handle. For an expected domain failure, return structured data:
parentPort.postMessage({
ok: false,
error: {
code: 'INVALID_IMAGE',
message: 'Unsupported image',
},
});
That lets the parent distinguish a rejected image from an infrastructure failure such as a crashed worker. An unexpected worker crash should be logged and the worker replaced according to the pool policy.
Do not serialize full secret-bearing stack traces to untrusted clients. Keep detailed diagnostics in protected logs and expose only the safe, actionable error information the client needs.
Worker observability
Track at least:
- queue depth;
- active workers;
- task duration;
- failure count;
- CPU;
- memory;
- event-loop delay in API;
- worker restarts.
These measurements connect user-visible latency to the pool's behavior. Without them, a worker pool can quietly become the bottleneck: requests continue arriving, the queue grows, and the API may look healthy until latency or memory usage becomes severe.
Backpressure
Suppose the API accepts work faster than the pool can complete it:
queue grows without bound
→ memory/latency explode
The queue needs a limit and the API needs a policy for exceeding it. Possible choices include:
- reject with
429or503; - use a durable external queue;
- cap the in-memory queue;
- use a client async job model with
202 Accepted.
There is no universal best policy. A short interactive task may be rejected quickly, while a report-generation request may be persisted and processed later. What matters is that overload is deliberate rather than an accidental memory leak.
202 Accepted
For work that may take a long time, an asynchronous job API avoids holding an HTTP request open for the entire operation:
POST /reports
→ 202
→ jobId
GET /reports/:jobId
→ status/result
The initial response acknowledges that the job was accepted, and the client uses the job ID to check status or retrieve the result. This is generally better than keeping an HTTP request open for five minutes, especially when proxies, clients, and load balancers have shorter timeouts.
Common mistakes
- assuming
awaitaround CPU work makes it parallel; - creating one worker per request;
- allowing an unbounded worker queue;
- using shell
execwith user input; - buffering huge
execoutput; - keeping in-memory sessions across a process cluster;
- using a worker thread as a durable queue;
- failing to shut down child processes;
- creating more workers than the CPU quota can support;
- running without observability.
Exercises
- Build a CPU-blocking Fibonacci/hash endpoint and measure its latency.
- Move the work to a worker thread.
- Create a small worker pool with a bounded queue.
- Transfer an
ArrayBufferand observe what happens to ownership. - Compare the memory behavior of
spawnandexec. - Fix the command-injection example by using spawn arguments.
- Fork a Node child process and exchange IPC messages.
- Explain the difference between
clusterand multiple containers. - Design a
202async report-job API. - Define a queue backpressure policy.
Mastery checklist
Explain:
- CPU blocking;
- worker threads;
- structured clone/transfer;
- worker pools;
- spawn/exec/execFile/fork;
- command injection;
- IPC;
- cluster;
- process memory isolation;
- durable queues;
- backpressure.
