Child processes — spawn, exec, and fork

Node's single-threaded model means genuinely running another program, or truly parallel Node code, requires an entirely separate OS process — child_process is the API for creating and communicating with one, and the three main functions each fit a different real shape of that need.

Advanced

3 min read

spawn: streaming output from a long-running or high-output command

const { spawn } = require("node:child_process");
 
const child = spawn("ping", ["example.com"]);
 
child.stdout.on("data", (chunk) => console.log(`output: ${chunk}`)); // stdout is a STREAM — chunks arrive over time
child.stderr.on("data", (chunk) => console.error(`error: ${chunk}`));
child.on("close", (code) => console.log(`process exited with code ${code}`));

spawn launches a new process and gives back streaming access to its stdout/stderr — the right choice for a long-running command, or one that produces a large amount of output, since the data arrives incrementally (the same streaming model this domain's earlier streams lesson covered) rather than being buffered entirely in memory before anything's available. ping, a build tool, a long-running data-processing script — anything where output should be handled as it's produced, not all at once at the end.

exec: convenient for a short command whose full output is needed at once

const { exec } = require("node:child_process");
 
exec("ls -la", (err, stdout, stderr) => {
  if (err) { console.error(err); return; }
  console.log(stdout); // the FULL output, buffered and handed back all at once
});

exec runs a command through a shell (meaning shell features like pipes and wildcards work directly in the command string) and buffers the entire output before calling back with it — simpler to use for a short command where the full result is needed anyway, but a real, practical limit exists: exec's default output buffer has a maximum size, and a command producing more output than that limit causes an error, which spawn's streaming approach never runs into since it never needs the whole output in memory at once.

fork: specifically for spawning another Node.js process, with a real message channel

// parent.js
const { fork } = require("node:child_process");
const child = fork("./worker.js");
 
child.send({ task: "process-data", payload: [1, 2, 3] }); // structured message, not just a raw stdout string
child.on("message", (result) => console.log("got result:", result));
 
// worker.js
process.on("message", (msg) => {
  const result = msg.payload.map((n) => n * 2);
  process.send(result); // sends a structured message BACK to the parent
});

fork is a specialized version of spawn specifically for launching another Node.js script, and it automatically sets up a built-in communication channel (.send()/process.on("message", ...)) for passing structured JavaScript objects back and forth — not raw text output that has to be parsed, the way spawn's stdout would require. This is the real, deliberate tool for splitting CPU-bound work (recall the earlier non-blocking-I/O lesson's point that non-blocking I/O does nothing for genuinely CPU-heavy work) off into a fully separate process, with structured communication built in rather than reinvented on top of raw stdout parsing.

Why this matters: genuine parallelism, unlike anything covered earlier in this domain

Every mechanism covered in this domain until now — the event loop, non-blocking I/O, streams — describes clever scheduling on a single thread, never genuine simultaneous execution. A child process is a real, separate OS process, with its own memory space and its own thread, actually running at the same time as the parent — true parallelism, at the cost of real overhead (process creation, and structured communication between processes requires explicit message-passing, since they don't share memory the way threads within one process would). The next lesson, worker threads, covers a lighter-weight alternative for CPU-bound parallelism specifically within a single Node application.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why is `spawn` the right choice for a long-running or high-output command, rather than `exec`?

2. What makes `fork` different from a generic `spawn` call?

3. What does a child process provide that everything covered earlier in this domain (event loop, streams, non-blocking I/O) cannot?