Child Processes, Worker Threads and Clustering

By Syncfusion®, Inc. · Last updated

When to use parallelism, recognising CPU-bound work, child processes with exec, execFile, spawn and fork, options, streaming output, IPC, killing and detaching processes and handling their errors, worker threads with workerData, parentPort, message passing, structured clone, transferable objects, errors, termination and worker pools, SharedArrayBuffer and Atomics, MessageChannel and resource limits, the cluster module, and choosing between child processes, worker threads and cluster.

Child Processes, Worker Threads and Clustering

Parallelism = doing several jobs at the very same moment, on different CPU cores.

  • Your JavaScript runs on one thread (see Event Loop and Timers).
  • Modern computers have 4, 8 or more cores. One thread uses only one.
  • Node.js can start child processes, worker threads and cluster workers.
  • Each one runs on its own, so heavy work stops blocking your main program.

Simple lesson: when one thread is not enough, give heavy work its own thread or process.

A Real-World Example

Think of exam correction at Greenfield School.

  • One teacher corrects 400 papers. It takes all night. Nobody can ask her anything.
  • Better: the principal gives 100 papers to each of 4 teachers in other rooms.
  • Each teacher works alone and sends back a list of marks.
  • The principal stays free to answer parents at the door.

The principal is the main thread. The other teachers are workers. The mark lists are messages.

What You Will Learn

In this lesson, you will learn:

  1. How to recognise CPU-bound work
  2. Child processes: exec, execFile, spawn and fork
  3. Options, streaming output, IPC and killing processes
  4. Worker threads: Worker, workerData and parentPort
  5. Message passing, structured clone and transferable objects
  6. Worker errors, termination and worker pools
  7. SharedArrayBuffer, Atomics, MessageChannel and resource limits
  8. The cluster module
  9. How to choose the right approach

When to Use Parallelism

CPU-bound work = work that keeps the processor busy, with no waiting.

  • Counting primes, image resizing, PDF making, heavy maths, compressing big files.
  • While it runs, timers, requests and events all wait.
  • Measure first: if a job takes more than about 50 ms, move it off the main thread.
  • I/O-bound work, such as reading files, does not need this. Node.js already handles it.

Measure first with performance.now(). Move work to a worker only when it really blocks.

Simple lesson: if heavy maths makes timers run late, move it to a worker.

Child Processes

Child process = another program started by your Node.js program.

  • It can be any program: git, ffmpeg, Python, or another Node.js script.
  • It has its own memory and its own stdin, stdout and stderr.
  • node:child_process gives four main ways to start one.
FunctionRunsOutputUse it for
exec(command)A command through the shellBuffered, given at the endShort commands with small output
execFile(file, args)A program directly, no shellBufferedSafer: arguments are not read by a shell
spawn(file, args)A program directlyStreamed as it comesLong jobs and big output
fork(script)A Node.js scriptStreamed, plus a message channelNode.js helpers that talk back
// main.mjs
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// execFile: no shell, arguments passed as a list (safer)
const { stdout } = await execFileAsync("node", ["--version"]);
console.log("Child said:", stdout.trim().startsWith("v") ? "a Node.js version" : stdout);

// A failing command gives an error with the exit code
try {
  await execFileAsync("node", ["-e", "process.exit(3)"]);
} catch (err) {
  console.log("Child failed with code", err.code);
}

Output:

Child said: a Node.js version
Child failed with code 3

spawn and Streaming Output

  • spawn gives you child.stdout and child.stderr as streams.
  • Output arrives while the child is still running.
  • The close event gives the exit code.
// main.mjs
import { spawn } from "node:child_process";

const child = spawn("node", ["-e", "for (let i = 1; i <= 3; i++) console.log('page', i)"]);

child.stdout.on("data", (chunk) => {
  // A chunk can hold several lines, so split it.
  for (const line of chunk.toString().trim().split("\n")) console.log("printer:", line);
});
child.on("close", (code) => console.log("Printer finished with code", code));

Output:

printer: page 1
printer: page 2
printer: page 3
Printer finished with code 0

Options

  • cwd = the folder the child starts in.
  • env = its environment variables. Pass { ...process.env, EXTRA: "1" } to add one.
  • shell: true = run through a shell. Needed for pipes and *. Dangerous with user input.
  • stdio: "inherit" = the child writes straight to your terminal.
  • timeout = kill the child if it runs too long.
  • signal = an AbortSignal that kills the child when aborted.

fork and IPC

IPC (inter-process communication) = sending messages between processes.

  • fork(script) starts a Node.js script with a built-in message channel.
  • The parent calls child.send(data). The child listens with process.on("message").
  • The child answers with process.send(data).
// main.mjs: one file plays both parts, chosen by an argument
import { fork } from "node:child_process";

if (process.argv[2] === "teacher") {
  // the child: correct one paper and send the mark back
  process.on("message", (paper) => {
    process.send({ name: paper.name, marks: paper.answers.filter(Boolean).length * 10 });
    process.exit(0);
  });
} else {
  const teacher = fork(import.meta.filename, ["teacher"]);
  teacher.on("message", (result) => console.log("Mark from child:", result));
  teacher.on("exit", (code) => console.log("Child exited with code", code));
  teacher.send({ name: "Asha", answers: [true, true, false, true] });
}

Output:

Mark from child: { name: 'Asha', marks: 30 }
Child exited with code 0

Killing, Detaching and Errors

  • child.kill("SIGTERM") = ask the child to stop.
  • detached: true with child.unref() = let the child keep running after the parent exits.
  • error event = the child could not even start, for example the program does not exist (ENOENT).
  • exit or close with a non-zero code = it started, but failed.
// main.mjs
import { spawn } from "node:child_process";

const missing = spawn("no-such-program");
missing.on("error", (err) => console.log("Could not start:", err.code));

const slow = spawn("node", ["-e", "setTimeout(() => {}, 10000)"]);
slow.on("exit", (code, signal) => console.log("Slow child stopped by", signal));
setTimeout(() => slow.kill("SIGTERM"), 100);

Output:

Could not start: ENOENT
Slow child stopped by SIGTERM

Never put user input into exec or shell: true. A name like x; rm -rf / would run as a command. Use execFile or spawn with an argument list. Cryptography and Security covers this.

Simple lesson: execFile for short commands, spawn for streams, fork for Node.js helpers with messages.

Animation: the main program starts a child process and reads its output through a pipe your Node.js appspawn("git", ["log"]) child processits own memory and PID

1. start it2. output and exit code

They share nothing. They only talk through pipes and messages.

A child process is a separate program. Data travels between you through pipes.

Worker Threads

Worker thread = another JavaScript thread inside the same process, with its own event loop.

  • Create it with new Worker(file, { workerData }) from node:worker_threads.
  • workerData = data the worker gets at the start.
  • parentPort = the worker's line back to the main thread.
  • Workers share the process, so they start faster and use less memory than child processes.

Animation: the main thread sends jobs to two worker threads through message ports and gets results back, while it keeps answering requests Main threadkeeps answering requests Worker 1counts primes Worker 2resizes photos

postMessage out, message event back

Workers do heavy work in parallel and talk to the main thread with messages.

// main.mjs: the same file is the main thread and the worker
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";

function countPrimes(limit) {
  let count = 0;
  for (let n = 2; n < limit; n++) {
    let prime = true;
    for (let d = 2; d * d <= n; d++) if (n % d === 0) { prime = false; break; }
    if (prime) count++;
  }
  return count;
}

if (isMainThread) {
  const worker = new Worker(new URL(import.meta.url), { workerData: { limit: 300000 } });
  worker.on("message", (n) => console.log("Worker found", n, "primes"));
  worker.on("error", (err) => console.log("Worker failed:", err.message));
  worker.on("exit", (code) => console.log("Worker exited with code", code));
  console.log("Main thread is free while the worker counts");
} else {
  parentPort.postMessage(countPrimes(workerData.limit));
}

Output:

Main thread is free while the worker counts
Worker found 25997 primes
Worker exited with code 0

Message Passing and Structured Clone

Structured clone = the way messages are copied between threads.

  • Objects, arrays, Map, Set, Date and Buffer copy fine.
  • Functions and class methods cannot be sent. You get a DataCloneError.
  • Each side gets its own copy. Changing one does not change the other.

Transferable Objects

  • Copying a 100 MB buffer takes time and memory.
  • Transfer = hand the memory over instead of copying it. Pass it in the transfer list.
  • After the transfer, the sender's buffer is empty (byteLength 0).

Worker Errors, Termination and Pools

  • An uncaught error in a worker fires the error event on the Worker object. The main thread keeps running.
  • worker.terminate() = stop a worker now.
  • Starting a worker takes time and memory. Do not start one per task.
  • Worker pool = a fixed number of workers (often one per core) that take tasks from a queue.
  • Libraries such as piscina give you a ready pool.

Browsers have Web Workers with the same idea: heavy work on another thread, and messages to talk.

  • Four papers start at once. Papers 5 and 6 wait in the queue for the first free workers.
  • A real pool does the same with real Worker objects.

SharedArrayBuffer, Atomics and MessageChannel (Advanced)

  • SharedArrayBuffer = memory that several threads see at the same time. No copying at all.
  • Atomics = safe operations on shared memory, such as Atomics.add, so two threads do not overwrite each other.
  • MessageChannel = a pair of connected ports. Give one port to a worker for a private line.
  • Resource limits = new Worker(file, { resourceLimits: { maxOldGenerationSizeMb: 64 } }) caps a worker's memory.
// main.mjs: four workers add to one shared counter safely
import { Worker, isMainThread, workerData } from "node:worker_threads";

if (isMainThread) {
  const shared = new SharedArrayBuffer(4);
  const counter = new Int32Array(shared);
  let finished = 0;
  for (let i = 0; i < 4; i++) {
    const w = new Worker(new URL(import.meta.url), { workerData: shared });
    w.on("exit", () => {
      if (++finished === 4) console.log("Counter:", Atomics.load(counter, 0), "(4 workers x 1000)");
    });
  }
} else {
  const counter = new Int32Array(workerData);
  for (let i = 0; i < 1000; i++) Atomics.add(counter, 0, 1);
}

Output:

Counter: 4000 (4 workers x 1000)

Simple lesson: workers share a process, talk by copied messages, and can share memory carefully with Atomics.

The cluster Module

cluster = start several copies of your server, one per CPU core, all sharing one port.

  • The primary process starts the copies with cluster.fork().
  • Each worker process runs the same server code.
  • Incoming connections are shared between the workers.
  • If a worker crashes, the primary can start a new one.
// main.mjs
import cluster from "node:cluster";
import http from "node:http";
import os from "node:os";

if (cluster.isPrimary) {
  const count = Math.min(2, os.availableParallelism());
  console.log(`Primary starting ${count} workers`);
  let ready = 0;
  for (let i = 0; i < count; i++) cluster.fork();
  cluster.on("listening", () => {
    if (++ready === count) {
      console.log("All workers share port 8123");
      for (const w of Object.values(cluster.workers)) w.kill();
    }
  });
  cluster.on("exit", (worker, code, signal) => {
    console.log(`Worker ${worker.id} stopped (${signal ?? code})`);
    // In a real server: cluster.fork() here to replace a crashed worker.
  });
} else {
  http.createServer((req, res) => res.end(`Hello from worker ${process.pid}`)).listen(8123);
}

Output:

Primary starting 2 workers
All workers share port 8123
Worker 1 stopped (SIGTERM)
Worker 2 stopped (SIGTERM)

Cluster vs a Reverse Proxy

  • Today, many apps run one process per container and let the platform start several copies.
  • A reverse proxy or load balancer (such as Nginx or a cloud balancer) shares traffic between them.
  • Process managers such as PM2 can also run a cluster for you.
  • Use cluster directly only on a single server without such tools.

Simple lesson: cluster copies a whole server per core, but containers and balancers often do this job now.

Choosing an Approach

NeedChild processWorker threadCluster
Run another program (git, Python)YesNoNo
Heavy JavaScript maths without blockingWorks, but heavyYes, best choiceNo
Use every core for one web serverNoNoYes
Share memoryNoYes, with SharedArrayBufferNo
Start-up costHighMediumHigh
A crash affectsOnly the childThe worker (handled by error)Only that worker process

Simple lesson: other programs → child process, heavy JS → worker thread, more server copies → cluster.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Using workers for file readingMore threads make I/O fasterI/O is already non-blockingExtra cost, no gain
exec with user inputThe shell is harmlessUse execFile or spawn with an argument listCommand injection
One new Worker per requestWorkers are freeUse a poolSlow start-up and memory blowing up
Sending a function in postMessageEverything can be sentSend plain dataDataCloneError
No error listener on a child or workerFailures are printedListen for error and exitSilent failures or crashes
Using a transferred buffer againTransfer copiesTransfer moves the memoryThe sender's buffer is empty
Big output through execOutput size is unlimitedUse spawn and streamsmaxBuffer error

TL;DR

  1. CPU-bound = keeps the processor busy and blocks the event loop.
  2. exec = shell command, buffered. execFile = no shell, safer.
  3. spawn = streams output. fork = Node.js script with send and message.
  4. Worker thread = another JS thread. workerData in, parentPort.postMessage out.
  5. Structured clone = messages are copied. Functions cannot be sent.
  6. Transfer = move memory instead of copying it.
  7. Worker pool = a few reusable workers and a queue.
  8. cluster = one server process per core, sharing a port.

Check Yourself

Exercises

What to Remember

  • Move CPU-bound work off the main thread. Leave I/O where it is.
  • execFile and spawn run other programs. Never pass user input to a shell.
  • fork starts a Node.js child with a message channel.
  • Worker threads run heavy JavaScript and talk with copied messages.
  • Transfer big buffers instead of copying them, and reuse workers in a pool.
  • cluster runs server copies, but containers and balancers often do it today.

Next: Testing with the Built-in Test Runner. You will check your code automatically with node:test.