Module: Nodejs
Nodejs·129·5 MIN READ

129: Worker Threads, Child Processes, Cluster, IPC, and CPU-Bound Work

TOPICS COVERED: 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_process safely;
  • distinguish spawn, exec, execFile, and fork;
  • 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:

js
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:

js
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

js
// worker.js
import {
  parentPort,
  workerData,
} from 'node:worker_threads';

const result = expensive(workerData);

parentPort.postMessage({
  result,
});

Parent:

js
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:

text
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:

js
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

js
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

js
exec('git status --short', ...);

Runs through a shell and buffers output.

Danger with untrusted input:

js
exec(`convert ${filename}`);

Shell injection.

Avoid for user-controlled values.

execFile

Runs executable directly without shell by default:

js
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.

js
import { fork } from 'node:child_process';

const child = fork('./child.js');

child.send({
  type: 'job',
  payload: ...
});

Child:

js
process.on('message', (message) => {
  ...
});

Useful for Node process isolation.

Exit and error events

Process lifecycle:

text
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:

js
const child = spawn(..., {
  signal: controller.signal,
});

where supported.

Also design kill escalation:

text
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:

text
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:

js
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:

text
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:

text
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:

js
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:

text
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:

text
POST /reports
→ 202
→ jobId
GET /reports/:jobId
→ status/result

Better than keeping HTTP request open for 5 minutes.

Common mistakes

  • await around CPU work assumed parallel;
  • worker per request;
  • unbounded worker queue;
  • shell exec with user input;
  • huge exec output;
  • in-memory sessions across process cluster;
  • worker thread used as durable queue;
  • no child shutdown;
  • too many workers for CPU quota;
  • no observability.

Exercises

  1. Build CPU-blocking Fibonacci/hash endpoint and measure latency.
  2. Move work to worker thread.
  3. Create a tiny worker pool with bounded queue.
  4. Transfer ArrayBuffer and observe ownership.
  5. Compare spawn and exec memory behavior.
  6. Fix command-injection example with spawn args.
  7. Fork a Node child and exchange IPC messages.
  8. Explain cluster versus multiple containers.
  9. Design 202 async report job API.
  10. 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.

Official references