Child Processes

Node.js runs your JavaScript on a single thread, but real programs often need to run other programs — a shell command, a Python script, an image converter, or even another Node.js file — without freezing that thread. The node:child_process module lets Node spawn separate operating-system processes, talk to them over pipes, and get real parallelism, since each child runs on its own OS process with its own memory and its own event loop. This lesson covers every way to create and manage child processes, how the underlying plumbing works, and the security traps that catch almost everyone at least once.

Overview: What Child Processes Are and Why You Need Them

Node’s event loop is great at concurrency (handling many I/O-bound tasks at once) but bad at parallelism (using multiple CPU cores) and bad at running things that simply are not JavaScript. When you need to run git, ffmpeg, a shell pipeline, or a CPU-heavy script written in another language, you don’t run it ‘inside’ Node — you ask the operating system to start a brand-new process for it. The node:child_process module is Node’s wrapper around the OS calls that do this (fork()/execve() on POSIX systems, CreateProcess on Windows), exposed through libuv.

There are four core functions, each suited to a different job:

  • spawn() — launches a command and gives you back live, streaming stdin/stdout/stderr. Best for long-running processes or large output.
  • exec() — runs a command through a shell and buffers all output into memory, handing it to a callback once the process exits. Convenient for short commands, but the shell step is a security risk with untrusted input.
  • execFile() — like exec(), but runs the executable directly with an array of arguments, no shell involved. Safer and slightly faster.
  • fork() — a specialised spawn() for launching another Node.js module as a child process, with a built-in message-passing channel (IPC) between parent and child.

Each of these also has a blocking, synchronous sibling — spawnSync, execSync, execFileSync — which halts the event loop until the child finishes. Those are fine in one-off CLI scripts or build tooling, but you should never call them inside a server request handler, because they freeze every other connection Node is serving.

Syntax

child_process.spawn(command, args, options)
child_process.exec(command, options, callback)
child_process.execFile(file, args, options, callback)
child_process.fork(modulePath, args, options)
Function Runs through a shell? Output delivery Typical use
spawn No (unless shell: true) Streaming (stdout/stderr are Readable streams) Long-running or high-volume output
exec Yes, always Buffered, passed to callback Short shell commands/pipelines
execFile No Buffered, passed to callback Running a known binary with args safely
fork No Streaming + IPC message events Running another Node.js script, worker-style

Common options across all of them include cwd (working directory), env (environment variables), timeout (kill the process after N ms), and maxBuffer (for exec/execFile, the max bytes of buffered output before the call errors — default 1 MB).

Examples

Example 1: Streaming output with spawn()

import { spawn } from "node:child_process";

const child = spawn("ls", ["-la", "."], { stdio: ["ignore", "pipe", "pipe"] });

child.stdout.on("data", (chunk) => {
  process.stdout.write(`stdout: ${chunk}`);
});

child.stderr.on("data", (chunk) => {
  process.stderr.write(`stderr: ${chunk}`);
});

child.on("error", (err) => {
  console.error("Failed to start subprocess:", err.message);
});

child.on("close", (code, signal) => {
  console.log(`child process exited with code ${code} (signal: ${signal})`);
});

Output:

stdout: total 24
stdout: drwxr-xr-x  5 user user 4096 Jul 31 10:00 .
stdout: -rw-r--r--  1 user user  220 Jul 31 10:00 app.js
child process exited with code 0 (signal: null)

spawn() returns immediately with a ChildProcess object whose stdout and stderr are ordinary Node streams, so you handle output as it arrives rather than waiting for the whole thing. The close event fires once the child’s stdio streams have all finished, which is usually what you want to wait on (as opposed to exit, which can fire slightly earlier, before streams finish flushing).

Example 2: Running a program safely with execFile()

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

async function getGitLog(repoPath, count) {
  try {
    const { stdout } = await execFileAsync(
      "git",
      ["log", `-${count}`, "--oneline"],
      { cwd: repoPath, timeout: 5000, maxBuffer: 1024 * 1024 }
    );
    return stdout.trim().split("\n");
  } catch (err) {
    console.error("git log failed:", err.message);
    throw err;
  }
}

const commits = await getGitLog(".", 5);
console.log(commits);

Output (example, depends on the repository):

[
  'a1b2c3d Fix streaming bug in upload handler',
  '9f8e7d6 Add rate limiting middleware',
  '5c4b3a2 Update dependencies',
  '1122334 Initial commit'
]

Because arguments are passed as an array, git is executed directly — no shell parses the string, so there is nothing for an attacker to inject even if count came from user input. promisify() turns the callback-based execFile into something you can await, which is the idiomatic modern style.

Example 3: Two-way communication with fork()

fork() is for launching another Node.js file as a full subprocess while keeping a message channel open. This is the basis of cluster workers and background-job runners.

worker.mjs

process.on("message", (msg) => {
  if (msg.type === "square") {
    const result = msg.value * msg.value;
    process.send({ type: "result", value: result });
  }
});

process.send({ type: "ready" });

parent.mjs

import { fork } from "node:child_process";
import path from "node:path";

const workerPath = path.join(import.meta.dirname, "worker.mjs");
const worker = fork(workerPath);

worker.on("message", (msg) => {
  if (msg.type === "ready") {
    worker.send({ type: "square", value: 7 });
  } else if (msg.type === "result") {
    console.log(`Result from worker: ${msg.value}`);
    worker.disconnect();
  }
});

worker.on("exit", (code) => {
  console.log(`worker exited with code ${code}`);
});

worker.on("error", (err) => {
  console.error("worker failed to start:", err.message);
});

Output:

Result from worker: 49
worker exited with code 0

Running node parent.mjs starts worker.mjs as its own OS process. worker.send()/process.send() serialize messages (as JSON-like structured data) and deliver them over an IPC pipe that fork() sets up automatically — spawn() and execFile() do not have this channel unless the target is itself a Node process launched with fork. Note the use of import.meta.dirname (Node 20.11+) instead of __dirname, which does not exist in ES modules.

How It Works Under the Hood

When you call any of these functions, libuv asks the operating system to create a genuinely separate process — on POSIX systems via fork() plus execve(), on Windows via CreateProcess. That child gets its own memory space, its own V8 instance (if it’s Node), and its own OS-level thread of execution scheduled independently by the kernel. This is real parallelism, not the cooperative concurrency you get from async/await on one thread.

Communication happens over stdio pipes. For spawn and fork, Node wires the child’s stdin/stdout/stderr file descriptors to pipes that the parent’s event loop watches for readability/writability — the same non-blocking I/O mechanism used for sockets and files, not the libuv thread pool. Data arrives in chunks as data events, exactly like reading a file or a network stream.

exec and execFile sit on top of the same pipes but accumulate every chunk into an internal buffer, then invoke your callback once with the whole result after the child exits — convenient, but it means you get nothing until the process is completely done, and a runaway process with huge output will exceed maxBuffer and error out.

fork() additionally opens a dedicated IPC file descriptor between the two Node processes. process.send() in the child and child.send() in the parent write structured-clone-serializable messages down that channel, and both sides emit a message event when data arrives — this is how cluster workers, hot-reload tooling, and background job queues talk to their subprocesses.

Common Mistakes

Mistake 1: Passing untrusted input to exec()

import { exec } from "node:child_process";

function listDirectory(userInput) {
  // DANGEROUS: userInput is concatenated straight into a shell command
  exec(`ls ${userInput}`, (err, stdout) => {
    if (err) return console.error(err);
    console.log(stdout);
  });
}

listDirectory("; rm -rf /tmp/important-data");

Because exec() runs the string through /bin/sh, anything that looks like shell syntax — ;, &&, |, backticks — is interpreted, not treated as a plain argument. Any user-controlled string reaching exec() is a command injection vulnerability. Fix it by switching to execFile() (or spawn()) with the arguments passed as a separate array, which never touches a shell:

import { execFile } from "node:child_process";

function listDirectory(userInput) {
  execFile("ls", [userInput], (err, stdout) => {
    if (err) return console.error(err);
    console.log(stdout);
  });
}

Mistake 2: Not listening for the error event

import { spawn } from "node:child_process";

const child = spawn("nonexistent-binary");

child.stdout.on("data", (data) => {
  console.log(data.toString());
});

If the executable doesn’t exist or can’t be launched (bad permissions, missing binary), ChildProcess emits an error event. An EventEmitter with no error listener throws, crashing the whole Node process. Always attach one:

child.on("error", (err) => {
  console.error("Failed to start subprocess:", err.message);
});

Two more traps worth knowing: exec/execFile reject with an ERR_CHILD_PROCESS_STDIO_MAXBUFFER-style error once combined output crosses maxBuffer (default 1 MB) — for large or unbounded output, use spawn() and stream it instead of buffering. And when locating a script to fork() from an ES module, remember __dirname doesn’t exist in ESM — use import.meta.dirname (Node 20.11+) or path.dirname(fileURLToPath(import.meta.url)).

Best Practices

  • Default to execFile() or spawn() with an arguments array; only reach for exec() when you genuinely need shell features like pipes or globbing, and never with untrusted input.
  • Always attach an error listener on every ChildProcess you create.
  • Prefer the close event over exit when you need to know the child’s stdio has fully flushed.
  • Set a timeout option for commands that could hang, so a stuck child doesn’t leak forever.
  • Use spawn() instead of exec() for large or unbounded output to avoid the default 1 MB maxBuffer limit.
  • Never call the *Sync variants (execSync, spawnSync) inside a request handler — they block the entire event loop.
  • Explicitly pass a minimal env instead of inheriting the full parent environment when launching processes with untrusted input.

Practice Exercises

  • Write a script that uses spawn() to run node --version and prints the version string once the child closes. Handle the error event in case Node isn’t on the PATH.
  • Write a function runCommand(file, args) built on execFile and promisify that returns { stdout, stderr }, and use it to safely list files in a directory the caller specifies.
  • Build a parent.mjs/worker.mjs pair with fork() where the parent sends a list of numbers and the worker replies with their sum. Make sure the worker process exits cleanly after replying.

Summary

  • spawn() streams output and is best for long-running commands or large output.
  • exec() runs through a shell and buffers output — convenient but risky with untrusted input.
  • execFile() runs a binary directly with an argument array, avoiding the shell entirely — the safe default.
  • fork() launches another Node.js module and adds a built-in IPC channel via send()/message.
  • Child processes are real OS processes scheduled by the kernel, giving true parallelism, not just concurrency.
  • Always handle the error event, avoid shell string interpolation, and prefer streaming over buffering for large output.