Child Processes & Worker Threads

1. Introduction ๐Ÿงต

JavaScript in Node runs on a single thread, but real applications often need true parallelism โ€” running another program, offloading heavy computation, or scaling across CPU cores. Node provides three complementary tools for this: child_process, worker_threads, and cluster.

2. Understanding Concurrency in Node.js ๐Ÿ”€

Concurrency (handling many things at once via the event loop) is different from parallelism (actually executing code simultaneously on multiple cores). Node's event loop gives you concurrency for free; child_process and worker_threads are how you get real parallelism.

Achieving Parallelism in Node
child_process โ€” separate OS processes
worker_threads โ€” separate threads, same process
cluster โ€” multiple Node processes sharing a port

3. Child Processes ๐Ÿ‘ถ

The child_process module (node:child_process) lets Node spawn and control other operating system processes โ€” including shell commands, executables, or other Node scripts.

3.1 spawn()

spawn() launches a command with streamed input/output โ€” ideal for long-running processes or large output volumes.

spawn-example.js

const { spawn } = require('node:child_process');

const child = spawn('ls', ['-la']);

child.stdout.on('data', (data) => console.log(`Output: ${data}`));
child.stderr.on('data', (data) => console.error(`Error: ${data}`));
child.on('close', (code) => console.log(`Exited with code ${code}`));

3.2 exec()

exec() runs a command in a shell, buffering the entire output and returning it via a callback โ€” convenient for short commands with small output.

exec-example.js

const { exec } = require('node:child_process');

exec('ls -la', (err, stdout, stderr) => {
  if (err) return console.error(err);
  console.log(stdout);
});

Caution

Because exec() runs through a shell, passing unsanitized user input into the command string can lead to command injection vulnerabilities.

3.3 execFile()

execFile() runs an executable directly, without a shell โ€” safer and slightly more efficient than exec() when you don't need shell features like pipes or globbing.

execfile-example.js

const { execFile } = require('node:child_process');

execFile('node', ['--version'], (err, stdout) => {
  if (err) return console.error(err);
  console.log(stdout);
});

3.4 fork()

fork() is a specialized spawn() for launching another Node.js module as a child process, automatically setting up an IPC channel for message passing.

fork-example.js

const { fork } = require('node:child_process');

const child = fork('./worker-script.js');

child.on('message', (msg) => console.log('From child:', msg));
child.send({ task: 'process', data: [1, 2, 3] });

4. IPC (Inter-Process Communication) ๐Ÿ“จ

When using fork(), parent and child communicate via .send() and the 'message' event โ€” a built-in IPC channel that serializes data as JSON-like messages.

ipc-worker-script.js

// worker-script.js
process.on('message', (msg) => {
  const result = msg.data.reduce((a, b) => a + b, 0);
  process.send({ result });
});

5. Standard Input & Output ๐Ÿ”Œ

Every child process (from spawn or fork) exposes stdin, stdout, and stderr as streams, letting the parent pipe data in and read output out.

stdio-piping.js

const { spawn } = require('node:child_process');

const grep = spawn('grep', ['error']);

grep.stdin.write('info: starting\n');
grep.stdin.write('error: connection failed\n');
grep.stdin.end();

grep.stdout.on('data', (data) => console.log(data.toString()));

6. Process Management ๐ŸŽ›๏ธ

process-management.js

const child = spawn('long-running-task');

child.kill('SIGTERM'); // request graceful termination
console.log(child.pid); // process ID
console.log(child.killed); // whether kill() was called

7. Worker Threads ๐Ÿงถ

The worker_threads module runs JavaScript in parallel threads within the same process โ€” each with its own V8 instance and event loop, but able to share memory via SharedArrayBuffer. Lighter weight than spawning a whole new process.

7.1 Creating Worker Threads

create-worker.js

const { Worker, isMainThread, parentPort, workerData } = require('node:worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename, { workerData: { num: 42 } });
  worker.on('message', (result) => console.log('Result:', result));
} else {
  const squared = workerData.num ** 2;
  parentPort.postMessage(squared);
}

7.2 Message Passing

Communication between the main thread and a worker happens via postMessage(), which structured-clones the data (copying it) rather than sharing memory directly.

message-passing.js

worker.postMessage({ type: 'compute', payload: [1, 2, 3] });

worker.on('message', (msg) => console.log('Worker replied:', msg));

8. Shared Memory ๐Ÿง 

8.1 SharedArrayBuffer

Unlike regular message passing (which copies data), a SharedArrayBuffer is actual shared memory that multiple threads can read and write directly, without serialization overhead.

shared-array-buffer.js

const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);

worker.postMessage({ sharedBuffer });

Warning

Shared memory reintroduces the classic race condition risks that Node's single-threaded model normally avoids โ€” synchronize access carefully.

8.2 Atomics

Atomics provides thread-safe operations on shared memory โ€” reading, writing, and waiting โ€” that are guaranteed not to be interrupted mid-operation by another thread.

atomics-example.js

Atomics.add(sharedArray, 0, 1);      // atomic increment
Atomics.store(sharedArray, 0, 42);   // atomic write
const value = Atomics.load(sharedArray, 0); // atomic read

9. Thread Pools ๐ŸŠ

Rather than spinning up a new Worker per task (which has real overhead), production systems often maintain a pool of reusable worker threads, distributing tasks among them.

worker-pool-sketch.js

const { Worker } = require('node:worker_threads');

class WorkerPool {
  constructor(size, script) {
    this.workers = Array.from({ length: size }, () => new Worker(script));
    this.nextWorker = 0;
  }

  run(data) {
    const worker = this.workers[this.nextWorker];
    this.nextWorker = (this.nextWorker + 1) % this.workers.length;
    return new Promise((resolve) => {
      worker.once('message', resolve);
      worker.postMessage(data);
    });
  }
}

10. CPU-Intensive Tasks ๐Ÿ”ข

Tasks like image processing, cryptographic hashing, or complex calculations block the event loop if run synchronously on the main thread. Offloading them to worker_threads keeps the server responsive.

cpu-intensive-offload.js

// fibonacci-worker.js
const { parentPort, workerData } = require('node:worker_threads');

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

11. Background Jobs ๐Ÿ•ฐ๏ธ

Longer background work (sending bulk emails, generating reports, processing uploads) is often delegated to a child process or an external job queue (like BullMQ backed by Redis) so it doesn't compete with request handling.

12. Clustering Overview ๐ŸŒ

The cluster module lets you fork multiple Node processes that share the same server port, distributing incoming connections across them โ€” a simple way to use all CPU cores for an HTTP server.

cluster-example.js

const cluster = require('node:cluster');
const os = require('node:os');
const http = require('node:http');

if (cluster.isPrimary) {
  const cpuCount = os.cpus().length;
  for (let i = 0; i < cpuCount; i++) cluster.fork();
} else {
  http.createServer((req, res) => res.end('Handled by worker ' + process.pid)).listen(3000);
}

13. Process vs Thread vs Cluster ๐Ÿ†š

ApproachMemoryBest For
child_processFully isolated (separate process)Running external programs, isolating crashes
worker_threadsIsolated by default, shareable via SharedArrayBufferCPU-bound JS computation within one app
clusterFully isolated (multiple processes)Scaling an HTTP server across CPU cores

14. Choosing the Right Approach ๐Ÿงญ

What are you trying to do?
Run an external program or script โ†’ child_process
Run CPU-heavy JS without blocking the event loop โ†’ worker_threads
Scale an HTTP server across all CPU cores โ†’ cluster

15. Performance Optimization โšก

  • Reuse a worker pool instead of creating a new Worker per task.
  • Use SharedArrayBuffer and Atomics to avoid costly data copying for large shared datasets.
  • Size your cluster workers to match os.cpus().length, not arbitrarily high.
  • Prefer execFile() over exec() when shell features aren't needed, for lower overhead and better safety.

16. Best Practices โœ…

  • Never pass unsanitized user input into exec(); prefer execFile() or spawn() with an argument array.
  • Always handle the 'error' and 'exit'/'close' events on child processes and workers.
  • Terminate workers explicitly (worker.terminate()) when they're no longer needed.
  • Use cluster alongside a process manager (like PM2) for automatic restarts on crashes.

17. Common Mistakes โš ๏ธ

  • Using exec() with untrusted input, opening the door to command injection.
  • Spawning a new Worker per request instead of reusing a pool, causing high overhead.
  • Forgetting that worker_threads don't share memory by default โ€” regular variables aren't automatically synchronized.
  • Not accounting for serialization cost when passing large objects via postMessage().
  • Running CPU-heavy synchronous code directly on the main thread instead of offloading it.

18. Frequently Asked Questions โ“

Question

When should I use worker_threads instead of child_process?

Answer

Use worker_threads for CPU-bound JavaScript computation within the same application. Use child_process when you need to run a separate program or want full process isolation.

Question

Does cluster share memory between workers?

Answer

No โ€” each cluster worker is a fully separate process with its own memory space; they only share the listening port via the primary process.

Question

Is SharedArrayBuffer safe to use without Atomics?

Answer

Technically you can read and write without Atomics, but doing so risks race conditions โ€” Atomics operations are what make concurrent access safe and predictable.

19. Summary ๐Ÿ“

Summary

Node offers three tools for real parallelism: child_process for running external programs, worker_threads for CPU-bound JavaScript within an app, and cluster for scaling a server across CPU cores. Combined with SharedArrayBuffer and Atomics for safe shared memory, these tools let Node applications overcome the limits of a single-threaded event loop when true concurrency is required.