FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·129·13 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

Consider an HTTP route that performs an expensive calculation:

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

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

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

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

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

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

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);
}

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:

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

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

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

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

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

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

The child receives messages through the process object:

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

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

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

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

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

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

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

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

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

text
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 429 or 503;
  • 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:

text
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 await around CPU work makes it parallel;
  • creating one worker per request;
  • allowing an unbounded worker queue;
  • using shell exec with user input;
  • buffering huge exec output;
  • 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

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

Official references

Reader page: /nodejs/lesson/129/worker-threads-child-processes-cluster-ipc-and-cpu-bound-work