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
A Node HTTP server:
app.get('/hash', (req, res) => {
const result = veryExpensivePureJavaScript();
res.json({ result });
});
If computation takes 2 seconds, the main JavaScript thread cannot serve normal callbacks during that time.
Async syntax does not fix it:
app.get('/hash', async (req, res) => {
const result = await Promise.resolve(
veryExpensivePureJavaScript(),
);
res.json({ result });
});
The expensive function still ran synchronously on the same thread.
Options
Optimize/remove work
Best option when possible.
Worker thread
Parallel JavaScript inside same process.
Child process
Separate process/memory.
External job queue/service
Durable, horizontally scalable background workload.
Choose based on isolation, durability, CPU, dependencies, and deployment.
Worker threads
// worker.js
import {
parentPort,
workerData,
} from 'node:worker_threads';
const result = expensive(workerData);
parentPort.postMessage({
result,
});
Parent:
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 demonstrates mechanics.
Do not create a fresh worker for every tiny request in production; startup overhead can dominate.
Worker pool
Architecture:
HTTP requests ↓ bounded job queue ↓ N persistent worker threads ↓ results
Pool size based on:
- available CPU;
- other application work;
- container quota;
- workload;
- memory.
Do not simply use os.cpus().length without considering environment.
Use mature worker-pool packages when appropriate.
Worker communication
Messages use structured-clone semantics.
Large data copies can be expensive.
Transferable objects can transfer ownership of ArrayBuffer-like memory rather than copy.
Example concept:
worker.postMessage(buffer, [buffer.buffer]);
After transfer, original view can become detached/unusable.
Understand ownership.
SharedArrayBuffer
Workers can share memory using SharedArrayBuffer/Atomics.
This introduces true shared-memory concurrency complexity:
- races;
- synchronization;
- atomics;
- deadlocks/livelock patterns.
Do not use shared memory unless performance requires it and team can reason about concurrency.
Message passing is simpler.
Worker environment
Workers have:
- separate JS isolate;
- own event loop;
- shared process resources in some ways;
- access to worker APIs.
They are not separate OS processes.
A fatal process crash can affect all workers.
For stronger isolation, use child processes/services.
Child process
Node can launch programs.
spawn
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);
}
spawn streams output and accepts argument array.
exec
exec('git status --short', ...);
Runs through a shell and buffers output.
Danger with untrusted input:
exec(`convert ${filename}`);
Shell injection.
Avoid for user-controlled values.
execFile
Runs executable directly without shell by default:
execFile(
'git',
['status', '--short'],
callback,
);
Safer than shell interpolation for fixed executable + validated args.
Still validate arguments and file paths.
fork
Specialized to launch another Node module with IPC channel.
import { fork } from 'node:child_process';
const child = fork('./child.js');
child.send({
type: 'job',
payload: ...
});
Child:
process.on('message', (message) => {
...
});
Useful for Node process isolation.
Exit and error events
Process lifecycle:
spawn/error stdout/stderr exit close
Understand difference in docs.
Always handle:
- spawn failure;
- non-zero exit;
- timeout/cancellation;
- output limits.
Buffered output limits
exec/execFile have max buffer limits.
For huge output, use spawn streaming.
Do not buffer untrusted command output indefinitely.
Timeouts and cancellation
Child process:
const child = spawn(..., {
signal: controller.signal,
});
where supported.
Also design kill escalation:
request graceful termination wait force kill if deadline
Platform semantics differ.
Zombie/orphan processes
If parent exits unexpectedly, child behavior depends on platform/options.
Production supervisors/containers need process-tree-aware shutdown.
Do not spawn background children and forget them.
Cluster
Node's cluster module can create multiple processes sharing server port behavior.
Conceptually:
one primary → several worker processes → connections distributed
Historically common for using multiple CPU cores.
Modern production often instead runs multiple independent Node processes/containers behind load balancer/process manager.
Know cluster because Node roadmap includes it and existing systems use it.
Do not assume cluster is required.
Shared-nothing processes
Separate processes do not share ordinary JS memory.
Therefore:
const sessions = new Map();
inside one process is not visible to others.
If load-balanced across workers, session/rate-limit/cache requiring global consistency needs external/shared store or sticky/session architecture.
PM2
PM2 can manage multiple Node processes, restart, logs, clustering.
Container orchestrators/systemd can also supervise.
Do not stack several supervisors without understanding who owns restart/signals.
Background jobs
For durable jobs:
image processing email invoice generation report export
a queue can be better than worker thread inside API process.
Benefits:
- retry;
- persistence;
- backoff;
- concurrency control;
- independent scaling;
- dead-letter behavior.
Worker thread alone is not durable.
CPU service architecture
If image processing dominates CPU/memory:
API Node service → queue → image worker service → object storage
often better than doing work inside API worker pool.
Separate resource limits/failures.
Native addons
Some packages perform CPU work in native code and may already use thread pool/worker internals.
Do not duplicate parallelism before profiling.
Worker error propagation
Return structured failure:
parentPort.postMessage({
ok: false,
error: {
code: 'INVALID_IMAGE',
message: 'Unsupported image',
},
});
Unexpected worker crash should be logged and worker replaced according to pool policy.
Do not serialize full secret stack traces to untrusted clients.
Worker observability
Track:
- queue depth;
- active workers;
- task duration;
- failure count;
- CPU;
- memory;
- event-loop delay in API;
- worker restarts.
Without metrics, a worker pool can quietly become bottleneck.
Backpressure
If API accepts work faster than worker pool:
queue grows without bound → memory/latency explode
Apply limits:
- reject 429/503;
- durable external queue;
- cap in-memory queue;
- client async job model (202 Accepted).
202 Accepted
For long work:
POST /reports → 202 → jobId GET /reports/:jobId → status/result
Better than keeping HTTP request open for 5 minutes.
Common mistakes
awaitaround CPU work assumed parallel;- worker per request;
- unbounded worker queue;
- shell
execwith user input; - huge
execoutput; - in-memory sessions across process cluster;
- worker thread used as durable queue;
- no child shutdown;
- too many workers for CPU quota;
- no observability.
Exercises
- Build CPU-blocking Fibonacci/hash endpoint and measure latency.
- Move work to worker thread.
- Create a tiny worker pool with bounded queue.
- Transfer ArrayBuffer and observe ownership.
- Compare spawn and exec memory behavior.
- Fix command-injection example with spawn args.
- Fork a Node child and exchange IPC messages.
- Explain cluster versus multiple containers.
- Design 202 async report job API.
- Define 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.
