Spawning Processes with spawn & fork
Sometimes a Node.js program needs to launch another program — a shell utility, a media encoder, a long-running build tool, or even another Node script — and react to its output as it arrives rather than waiting for it to finish. The node:child_process module provides several ways to do this, but spawn and fork are the two you reach for when you care about streaming and inter-process communication. This page explains how they work, how to pass arguments safely, and when to choose them over exec.
How spawn works
spawn starts a new process and gives you back a ChildProcess object whose stdout, stderr, and stdin are streams. Because data is streamed rather than buffered into a single string, spawn is ideal for long-running commands or commands that produce large amounts of output — you process each chunk as it appears instead of holding the whole result in memory.
spawn does not run the command inside a shell by default. You pass the executable name and an array of arguments separately, which means there is no shell interpolation and therefore no shell-injection risk from unescaped user input.
import { spawn } from "node:child_process";
const child = spawn("ping", ["-c", "3", "example.com"]);
child.stdout.on("data", (chunk) => {
process.stdout.write(`out: ${chunk}`);
});
child.stderr.on("data", (chunk) => {
process.stderr.write(`err: ${chunk}`);
});
child.on("close", (code) => {
console.log(`process exited with code ${code}`);
});
Output:
out: PING example.com (93.184.216.34): 56 data bytes
out: 64 bytes from 93.184.216.34: icmp_seq=0 ttl=56 time=11.4 ms
out: 64 bytes from 93.184.216.34: icmp_seq=1 ttl=56 time=10.9 ms
out: 64 bytes from 93.184.216.34: icmp_seq=2 ttl=56 time=11.1 ms
process exited with code 0
The data events deliver Buffer objects. Call child.stdout.setEncoding("utf8") if you would rather receive strings, or collect the chunks and decode them yourself.
Pass
{ shell: true }only when you genuinely need shell features like pipes or globbing. Doing so reopens the door to command injection — never interpolate untrusted input into a shell command string.
Passing arguments and options
Arguments always go in the second parameter as an array. The third parameter is an options object that controls the child’s environment, working directory, and how its stdio is wired up.
import { spawn } from "node:child_process";
const child = spawn("git", ["log", "--oneline", "-n", "5"], {
cwd: "/path/to/repo",
env: { ...process.env, GIT_PAGER: "cat" },
stdio: ["ignore", "pipe", "inherit"],
});
let log = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => (log += chunk));
child.on("close", () => console.log(log.trim()));
The stdio array maps the child’s stdin, stdout, and stderr. Common values:
| Value | Meaning |
|---|---|
"pipe" | Create a stream you read/write from the parent (default) |
"inherit" | Share the parent’s own stdin/stdout/stderr |
"ignore" | Discard the stream (/dev/null) |
"ipc" | Open an IPC channel (used internally by fork) |
Setting stdio: "inherit" is a shortcut that wires all three streams straight to the parent, so the child’s output appears in your terminal with no manual piping.
Forking a Node process with IPC
fork is a specialization of spawn for launching a new Node.js process that runs a given module. On top of the normal stdio, it automatically opens an IPC channel, so the parent and child can exchange structured messages with send() and the "message" event. Values are serialized for you, so you can pass plain objects.
// parent.mjs
import { fork } from "node:child_process";
const child = fork("./worker.mjs");
child.on("message", (msg) => {
console.log("parent received:", msg);
child.disconnect();
});
child.send({ task: "sum", numbers: [1, 2, 3, 4] });
// worker.mjs
process.on("message", (msg) => {
if (msg.task === "sum") {
const total = msg.numbers.reduce((a, b) => a + b, 0);
process.send({ result: total });
}
});
Output:
parent received: { result: 10 }
Each forked process is a fresh V8 instance with its own memory and event loop, which makes fork a simple way to offload CPU-bound work without blocking the main process. For tighter, lower-overhead parallelism within one process, consider node:worker_threads instead — forks are heavier because they are full OS processes.
Always tear down forked children. Call
child.disconnect()orchild.kill()when work is done, and listen for"exit"; orphaned child processes keep the parent’s event loop alive and leak resources.
Choosing spawn vs exec vs fork
All three live in node:child_process but solve different problems.
| Method | Best for | Output handling | Shell | IPC |
|---|---|---|---|---|
spawn | Long-running commands, large or streaming output | Streamed via stdio streams | No (unless shell: true) | No |
exec | Short commands whose full output fits in memory | Buffered into a single callback (stdout, stderr) | Yes (runs in a shell) | No |
fork | Running another Node module as a subprocess | Streamed, plus an IPC channel | No | Yes |
In short: use spawn when you want to stream, exec when you just want a command’s complete output as a string and the convenience of shell syntax, and fork when the child is itself Node and you want to message it. execFile sits between spawn and exec — it buffers like exec but skips the shell like spawn.
Best practices
- Prefer the argument-array form of
spawn/forkovershell: trueto eliminate command-injection risk. - Stream and process output incrementally for long-running or high-volume commands instead of buffering it all.
- Always handle the
"error"event — it fires when the executable cannot be found or fails to spawn, separate from a non-zero exit code. - Inspect the
codeandsignalarguments of the"close"/"exit"events to distinguish clean exits from crashes or kills. - Clean up children explicitly with
kill()ordisconnect(), and propagate shutdown signals so subprocesses do not become orphans. - Reach for
worker_threadsinstead offorkwhen you need shared-memory or many lightweight workers; reserveforkfor true process isolation.