spawn(), exec(), execFile(), and fork()
By the time the next line of your own code runs after spawn(), the child is already running. In that one call, Node created a JavaScript object, asked the operating system to start a second process, wired up a few handles, and handed control back to you. The child gets its own memory, PID, working directory, and a copy of the environment, none of it shared with the parent.
Here is the smallest cross-platform child process.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 500)"]);
console.log(child.pid);
console.log(child.spawnfile);
console.log(child.spawnargs);Run that and the parent keeps going. child.pid is the process id the operating system handed back, assuming startup worked. child.spawnfile is the executable Node launched. child.spawnargs is the full argument list Node built for it.
process.execPath points at the Node binary you are already running, which keeps the example portable. Every machine that can run this code already has that executable, so there is no grep or cmd.exe that might be missing on someone's system. The child is just another Node process running a tiny -e program that stays alive for half a second.
Everything in this subchapter comes from one module, node:child_process. It gives you four asynchronous ways to start a process, spawn(), exec(), execFile(), and fork(), plus synchronous versions, spawnSync(), execSync(), and execFileSync().
When your program starts another program, that second program is a child process. Your program is the parent. In the Node docs you will also see the word subprocess, which means the same thing seen from the parent's side. The parent holds the JavaScript object. The child is a real operating system process with its own state.
Your JavaScript objects, closures, and module cache all live in the parent process and never exist in the child. The child can receive only strings, file descriptors, environment variables, command-line arguments, the stdio streams Node wires up, and for fork() an IPC channel for passing messages. Object references do not carry across the two processes.
That isolation is why you would start a child process at all. A crash in the child leaves the parent running. A memory cap keeps a risky job from taking the whole process down. Setting cwd to a scratch directory confines the child's relative-path access to that directory. A stripped env keeps the parent's credentials out of reach. The operating system enforces all of this, so it does not depend on careful JavaScript.
What the Parent Sets Up
Process creation begins with what you pass in. Node reads the command, the argument list, the options object, and the stdio configuration, and turns them into a set of native options. Those go to libuv, the C library Node uses for its event loop and operating system calls. libuv then calls the actual process-creation primitive the platform provides.
Here is the whole chain, from your call down to a running child and back.
parent JavaScript
-> child_process API
-> Node native process wrapper
-> libuv process handle
-> operating system process creation
-> child program entrypoint
-> ChildProcess events in the parentThat array of strings you pass as the second argument has a name, the argument vector. In spawn(process.execPath, ["-p", "process.version"]), process.execPath is the executable and the child gets -p and process.version as its arguments, exactly as written. No shell parses them unless you ask Node for one.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.argv.slice(1)"]);
child.stdout.on("data", chunk => process.stdout.write(chunk));That prints the arguments the child saw after its own executable name. The API takes an array because the operating system receives arguments already split into separate strings, rather than as one command line for a shell to parse. Windows builds its command line differently at the system level, but the Node API you write against still takes args as an array of strings on every platform.
When the child is another Node process started through process.execPath, how you launch it changes what process.argv holds inside the child. Launch with -e and there is no script filename, so the child's argv is the executable path followed by whatever arguments survive after Node consumes its own flags. Launch with a file path and the child gets the executable path, then the script path, then your application arguments. You saw process.argv back in Chapter 5. With child processes, the parent decides the child's argument vector before the child starts running.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "console.log(process.argv)", "x"]);
child.stdout.pipe(process.stdout);That prints how the child sees its own command line. Notice the parent's own process.argv is untouched. The parent built a fresh argv for the child without changing its own.
The cwd option sets the child's current working directory, the directory relative paths resolve against inside that process. It changes the child's working directory only. The parent's own working directory is unchanged.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.cwd()"], {
cwd: process.cwd(),
});
child.stdout.pipe(process.stdout);That prints the parent's own directory, because the code copied the same value into cwd. A different directory that exists makes the child print that path, with the parent's directory unchanged. A directory that does not exist makes startup fail with ENOENT.
cwd also affects how a command with a relative path in it gets found. spawn("./tool", [], { cwd: projectDir }) resolves ./tool starting from projectDir. A bare name like tool with no slash in it does not use cwd at all, it goes through PATH lookup instead. Those are two different lookup rules. A command can resolve from one directory and fail from another depending on which rule applies.
The env option sets the environment the child sees. Leave it out and the child inherits process.env, a copy of the parent's environment. Pass your own object and that object becomes the child's entire environment.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.env.MODE"], {
env: { ...process.env, MODE: "child" },
});
child.stdout.pipe(process.stdout);The child's environment now has MODE=child, and the parent's environment did not change. This is common in test runners, CLIs, and workers that need different credentials or a different PATH than the parent has.
Replacing env is all or nothing. Pass { MODE: "child" } and the child gets that one key, plus whatever the platform adds during lookup, and nothing more. HOME, PATH, NODE_OPTIONS, and anything your service manager injected are absent unless you copied them across. That empty starting environment is useful for isolation, and it also breaks tools that depend on a fuller environment being present.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.env.PATH"], {
env: { MODE: "clean" },
});
child.stdout.pipe(process.stdout);On Unix-like systems that child prints undefined for process.env.PATH. The executable itself was still found, because process.execPath is an absolute path and the lookup for it finished before the child ever ran any code.
Two terms are useful here. Command lookup is turning a name like node into an actual executable file on disk. PATH lookup is the specific step that searches through the directories listed in the PATH variable to do it. A command with a slash in it points straight at a file and skips the search. A bare name with no slash goes through the search.
import { spawn } from "node:child_process";
const child = spawn("node", ["-p", "process.version"], {
env: { PATH: process.env.PATH },
});
child.stdout.pipe(process.stdout);That version only works if node can be found through PATH. spawn(process.execPath, ...) sidesteps the whole search and uses the absolute executable path directly. When you pass env, Node uses options.env.PATH for the lookup. If that object has no PATH key, Unix-like systems fall back to a default search path and Windows keeps using the current process's PATH. So a minimal env built for tidiness, with PATH left out, is enough to make a command that resolved a moment ago stop resolving.
On Windows, environment variable names ignore case, so PATH, Path, and path are the same variable. Node sorts the keys and passes along the first case-insensitive match it finds. Pick one spelling and use it consistently, or you cannot be sure which value wins.
PATH bugs usually show up immediately, as a startup error. The executable never launches, so no diagnostic comes from the child. When something runs fine in your terminal but fails under a test runner or a service, print the cwd and the exact PATH you passed the child, and compare them. Your shell startup files, package scripts, version managers, and service managers each rewrite PATH, so it is rarely identical in two places.
Using a direct path makes all of this simpler.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.version"]);
child.stdout.pipe(process.stdout);Nothing has to be looked up to find the executable. Lookup can still happen inside the child itself, if that child goes on to start other commands.
The ChildProcess Object
Every async creation call, spawn(), exec(), execFile(), and fork(), hands you back the same kind of object, a ChildProcess. It is an EventEmitter carrying process metadata, the stdio streams, and a few methods for controlling the child.
The object comes back right away, before the underlying process has necessarily started.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "console.log('ready')"]);
child.on("spawn", () => console.log("spawned"));
child.stdout.on("data", chunk => process.stdout.write(chunk));
child.on("close", code => console.log({ code }));The 'spawn' event fires once the child has actually started, and it always fires before any stdout or stderr data arrives. The 'close' event fires at the other end, after the process has exited and every stdio stream tied to it has finished.
These lifecycle events are how the parent tracks where the child is. They report startup success or failure, process exit, stdio streams closing, and IPC messages arriving or the channel disconnecting. This subchapter only needs the process and stdio-close events. IPC gets its own treatment in Subchapter 3.
The four you deal with most are these.
'spawn' -> operating system process creation succeeded
'error' -> startup, kill, abort, or IPC send failure surfaced
'exit' -> process ended with an exit code or signal
'close' -> process ended and configured stdio streams closed'exit' can fire while the stdio streams are still open, which is expected. The process has ended, but the parent may still have buffered bytes in the pipes that have not been read. When your result depends on having every byte of stdout and stderr, wait for 'close' rather than 'exit'.
The order they fire in is diagnostic when something breaks. A short child that works usually goes 'spawn', then stdout and stderr data, then 'exit', then 'close'. A missing executable never emits 'spawn' at all and jumps straight to 'error'. A shell that starts fine but then cannot find the command inside it still emits 'spawn', because the shell process itself did start, it was the command it ran that failed.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "console.log('data')"]);
const events = [];
child.on("spawn", () => events.push("spawn"));
child.stdout.on("data", () => events.push("data"));
child.on("close", () => console.log(events));That array should always have spawn sitting before data. Do not write tests that pin down every internal ordering detail, most of that is not guaranteed, but 'spawn' arriving before stdio data is part of the documented contract and you can rely on it.
subprocess.pid is the child's process id once startup succeeds. If startup fails, it is undefined. Operating systems recycle PIDs after a process exits, so a PID you saved earlier is only meaningful while that child is still alive. After it exits, the same number can belong to something else entirely.
subprocess.spawnfile is the executable Node launched. subprocess.spawnargs is the full argument list it built. Both are useful mainly for debugging, when you want to see exactly what got run rather than what you think got run.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-p", "process.platform"]);
console.log(child.spawnfile);
console.log(child.spawnargs);What spawnfile holds depends on which API you used. With fork() it is the Node executable. With exec() it is the shell. With spawn() it is whatever command name or path you passed in.
kill() sends a signal, or on Windows a platform-specific termination request, to the child. The name overstates what it does. Sending a signal does not guarantee the child stops. The child can catch a signal and keep running, and Windows implements the supported signal names through its own mechanics rather than real signals. Chapter 5 covered signals and exit codes. kill() is a request from the parent. It does not guarantee anything about the child.
subprocess.killed reports only that Node sent the signal. It does not report whether the child process terminated. For that, read exitCode, signalCode, or wait for 'exit' or 'close'.
spawn() as the Baseline
Start with spawn(). The other calls build on it. You give it a command and an argument vector, it returns a ChildProcess, and by default it hands you the child's output as streams instead of buffering it.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "console.error('err'); console.log('out')"]);
child.stdout.on("data", chunk => process.stdout.write(`stdout: ${chunk}`));
child.stderr.on("data", chunk => process.stderr.write(`stderr: ${chunk}`));The child writes to its own stdout and stderr. The parent reads those bytes off child.stdout and child.stderr, which are Readable streams whenever those stdio slots are pipes. How the streams and pipe backpressure actually work is the next subchapter. spawn() gives you the output a chunk at a time, as it arrives.
That makes spawn() a good match for long-running commands, commands that print a lot, and cases where you want to see output before the child finishes. A compiler or a package manager can stream output for seconds or minutes. Buffering all of it in memory until the process exits, then handing it to your callback in one piece, wastes memory and delays output you could already be reading.
Node needs four separate inputs to start a process.
file: executable to launch
args: argument vector
env: environment snapshot
cwd: current working directoryThose four line up almost one to one with libuv's own process options. Underneath the JavaScript API, Node assembles a native process request and fills it in. The executable path goes into file, the arguments into args, the environment strings into env, the working directory into cwd, and the stdio wiring into a stdio array. A uv_process_t handle then tracks the running child and gets an exit callback when the operating system reports the process ended.
Asynchronous here means the call starts process creation and immediately returns control to your JavaScript, while the child runs on its own. Your event loop stays free the whole time, still handling timers, I/O callbacks, and promise jobs. The child, meanwhile, holds real operating system resources while it runs, a PID, memory, handles, and any files or sockets it opens.
The word async refers to the parent's API. It does not mean the work is free. Node keeps your JavaScript thread free once the request is in flight, but the operating system still has to allocate process state, map the executable into memory, set up handles, and schedule another process to run. Starting a few children is cheap. Starting thousands takes planning, including the process lifecycle and resource limits.
Startup failures and runtime failures reach you through different events. Some failures happen before your program ever runs, like a missing executable, a cwd that does not exist, a permission problem, a bad uid or gid, or the system being out of resources. All of those arrive as an 'error' event on the ChildProcess. Other failures happen after the shell or executable is already running. The program exits with code 1, the script prints an error and quits, or a command inside a shell string fails. Those are not errors as far as Node is concerned. They are the child exiting, and you read them as a normal exit.
import { spawn } from "node:child_process";
const child = spawn("__nodebook_missing_command__");
child.on("error", err => console.error(err.code));
child.on("close", (code, signal) => console.log({ code, signal }));On most systems that prints ENOENT, then a close event whose exact code fields vary by platform. 'spawn' never fires here, because the operating system never created the target program in the first place.
A command that starts and then fails behaves differently.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "process.exit(7)"]);
child.on("spawn", () => console.log("started"));
child.on("exit", code => console.log({ code }));This time startup works. The child runs and exits with code 7 on purpose. The parent sees 'spawn', then 'exit', then 'close' if it is listening for it. When you hit an error, the first thing to check is which stage it came from, whether startup, the program running, the exit status, or stdio closing. Each stage has a different cause.
One more thing spawn() does by default is leave the child's stdin open. A program that reads until end-of-file will wait indefinitely if the parent never writes anything and never closes stdin. This is why grep needle with no input hangs. It is still waiting to read from stdin. If you want an example that finishes, feed the child input, close its stdin, or run a child that exits on its own.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "process.stdin.resume()"]);
child.stdin.end();
child.on("close", code => console.log({ code }));The parent closes the child's stdin. With no more input coming, the child stops waiting and exits.
The Native Implementation
spawn() returns a JavaScript object immediately, but the process creation itself happens in the native layer beneath it.
First, Node's JavaScript layer checks and normalizes what you passed. The command has to be a string. Every entry in args is coerced to a string. Options like cwd, env, stdio, shell, uid, gid, windowsHide, and windowsVerbatimArguments get turned into internal fields. Then it returns a ChildProcess you can attach listeners to right away, even though the actual process does not exist yet.
Below that, Node's native wrapper connects the object to libuv, which exposes one function for this, uv_spawn(loop, handle, options). That options structure carries an exit callback, plus file, args, env, cwd, some flags, a stdio count, and an array describing the stdio. Once startup succeeds, the handle gets a PID, and later, when the process ends, that same exit callback carries the termination info back. The stdio entries define what the child sees as fd 0, fd 1, fd 2, and any extra descriptors you set up. Node defaults the first three to pipes, which is why the parent ends up with a writable stdin and readable stdout and stderr.
On Unix-like systems, libuv ends up calling the platform's process-creation primitives, which start a new program image with the argument vector and environment you chose. On Windows, process creation takes a single command-line string instead, so libuv and Node have to build that string back up from your argument array. That difference is why the Windows quoting options and windowsVerbatimArguments exist at all. Your API keeps command and args as separate values, but the operating system underneath does not always agree on that.
Once creation succeeds, the two processes run independently. The parent holds onto a libuv handle so it can collect the exit status later. The child starts running the program you asked for. If that program is another Node binary, it starts a fresh V8 instance, builds its own module cache, reads its own arguments and environment, and runs its entrypoint. The parent's heap stays in the parent. Anything that moves between them after this point has to go through stdio, files, sockets, signals, or IPC.
Exit reporting comes back the same way, through the native handle. The operating system says the process ended, either with an exit status or because a signal terminated it. libuv calls Node's exit callback. Node fills in exitCode and signalCode, emits 'exit', and only then waits for the stdio streams to close before emitting 'close'. That gap between 'exit' and 'close' is the reason code that needs the full output waits on 'close'. The child can have exited while its last bytes are still in flight to the parent's stream wrappers.
Startup errors are handled separately. If libuv cannot spawn the process at all, Node emits 'error' on the ChildProcess instead. A missing executable and a missing cwd both tend to come back as ENOENT, even though they mean two different things are missing. So when you debug, print the command, the cwd, and env.PATH together. A missing command and a missing directory look identical if all you log is err.code.
spawn() returns the instant you call it because the object and the process are created in separate layers. The JavaScript object is ready immediately. The process created from it reports back later, through events. These are three separate things with separate lifetimes. The object is state in your process. The child is state in the operating system. The pipes are kernel or platform handles that Node wraps as streams. The events tell you which one changed.
exec() and Buffered Shell Commands
exec() runs a full command string through a shell, waits for it to finish, and hands you the collected stdout and stderr in a callback.
import { exec } from "node:child_process";
exec("node -p \"process.version\"", (error, stdout, stderr) => {
if (error) throw error;
process.stdout.write(stdout);
});Your callback gets error, stdout, and stderr once the command has finished. A shell parses the string first, which is what exec() is for. Variable expansion, redirection, pipes, and operators like && and ; are all handled by the shell, not by Node.
exec() is convenient when the thing you run is genuinely a shell command and its output is small. Node keeps all of stdout and stderr buffered in memory until the command finishes, so anything that prints without a clear limit will grow that buffer until it hits the ceiling.
That ceiling is maxBuffer, the byte limit on buffered stdout or stderr for both exec() and execFile(). In Node v24 it defaults to 1024 * 1024 bytes per stream. Cross it and Node kills the child and calls your callback with an error.
import { exec } from "node:child_process";
exec("node -e \"process.stdout.write('x'.repeat(2000))\"", {
maxBuffer: 1024,
}, error => {
console.error(error.code);
});That prints ERR_CHILD_PROCESS_STDIO_MAXBUFFER on current Node. The child wrote valid output. The parent set a 1024-byte limit, and the output exceeded it.
The other difference is the shell itself. With exec(), your string goes to /bin/sh on Unix-like systems and to whatever process.env.ComSpec points at on Windows. That shell can launch other programs, expand variables, interpret quotes, and act on metacharacters. The full safety story for untrusted input is Subchapter 4. Until then, use exec() only for command strings you wrote yourself.
A single error object from exec() can come from several causes. A non-zero exit code produces one. So does a signal killing the child, a maxBuffer overflow, or a timeout firing. Whatever the cause, the callback still gives you the stdout and stderr Node collected before it stopped.
Because that one object is overloaded, production code usually wraps exec() and normalizes the result. The wrapper records the command, the cwd, the exit code, the signal, the stdout and stderr lengths, and the original error in one place. The raw callback provides only those values, so the wrapper has to keep enough context to tell which kind of failure occurred.
import { exec } from "node:child_process";
exec("node -e \"process.exit(9)\"", error => {
console.error(error.code);
});That prints 9, because the shell command ran and the program exited with status 9. That differs from ERR_CHILD_PROCESS_STDIO_MAXBUFFER, where the parent kills a healthy child for exceeding the buffer limit.
execFile() and Direct Executables
execFile() runs an executable directly, with no shell by default, then buffers stdout and stderr and hands them to a callback. It combines the direct launch of spawn() with the buffered callback of exec().
import { execFile } from "node:child_process";
execFile(process.execPath, ["-p", "process.platform"], (error, stdout) => {
if (error) throw error;
process.stdout.write(stdout);
});Here the executable is process.execPath and the arguments are ["-p", "process.platform"]. No shell parses any of it. The arguments reach the child exactly as you wrote them in the array.
Use it for a plain program launch when you already know the output is small and bounded.
import { execFile } from "node:child_process";
execFile(process.execPath, ["-e", "process.stdout.write('x'.repeat(2000))"], {
maxBuffer: 1024,
}, error => {
console.error(error.code);
});You get the same ERR_CHILD_PROCESS_STDIO_MAXBUFFER error. Removing the shell changes how arguments are parsed, but the output is still held in memory, so the same limit applies.
On Unix-like systems, direct execution works for any normal executable you can name or find on PATH. On Windows, .bat and .cmd files are not real executables. They need the command processor to run them, so use exec() or launch cmd.exe yourself when one of those is the target. The deeper quoting and injection rules are, again, Subchapter 4.
execFile() does take a shell option, but be careful with it. In Node v24, passing an args array together with { shell: true }, on either execFile() or spawn(), is a runtime deprecation. When a shell is involved, those argument values are joined into the command line instead of being escaped as separate shell words, which removes the injection safety you get from a direct launch. To launch a program directly, pass the args array and leave shell off.
Going direct also changes which errors you should expect. A missing executable fails at startup, a program that rejects one of its options fails by exiting, and a program that prints too much fails on the parent's buffer. Because no shell is involved, a mangled argument cannot be a quoting problem.
execFile() suits tools that produce one bounded result and stop, like git rev-parse HEAD, node -p process.version, or openssl version. Once the output no longer has a known ceiling, spawn() is the better choice, because its streams let the parent start consuming data while the child is still producing it.
import { execFile } from "node:child_process";
execFile(process.execPath, ["-p", "JSON.stringify(process.versions)"], {
encoding: "utf8",
}, (error, stdout) => {
if (error) throw error;
console.log(JSON.parse(stdout).node);
});That buffers a small JSON string and parses it once the child exits. Wrap the same pattern around a command that might print megabytes, though, and you need to either raise maxBuffer deliberately or switch to spawn().
fork() for Node Children
fork() starts a brand new Node process, runs a module you point it at, and returns a ChildProcess that already has an IPC channel wired up.
The name is misleading. This has nothing to do with the POSIX fork() that clones a running process's memory. Node's fork() starts a fresh Node executable, loads your module into it, and sets up IPC. The parent's heap objects stay in the parent and are not copied into the child.
Create a tiny child module.
process.send?.({
pid: process.pid,
argv: process.argv.slice(2),
});Save that as worker-child.js, then start it.
import { fork } from "node:child_process";
const child = fork(new URL("./worker-child.js", import.meta.url), ["job-42"]);
child.on("message", message => console.log(message));
child.on("close", code => console.log({ code }));Because the child is a Node process, it has process.send() available whenever the IPC channel is there, and the parent gets a 'message' event on the other end. Serialization, send queues, passing handles across, and IPC backpressure are all Chapter 14.3. For now, the relevant part is how you start it. fork() is how one Node process launches another and exchanges messages with it.
By default, fork() runs the same process.execPath the parent is running, and it forwards process.execArgv too unless you set execArgv yourself. So inspector flags, --require preloads, and other Node-level flags can carry into the child. Sometimes that is what you want. Other times the child inherits a debugger port or a memory flag you never intended to pass on.
import { fork } from "node:child_process";
const child = fork(new URL("./worker-child.js", import.meta.url), [], {
execArgv: [],
});
child.on("message", message => console.log(message));That starts the same module but wipes the inherited Node flags first. The IPC channel is still there, because fork() always sets one up.
fork() also handles stdio differently from spawn(). By default it inherits the parent's stdio, so the child's output lands straight in your terminal instead of in pipes. To get child.stdout and child.stderr streams on the parent, pass silent: true.
import { fork } from "node:child_process";
const child = fork(new URL("./worker-child.js", import.meta.url), [], {
silent: true,
});
child.stdout.on("data", chunk => process.stdout.write(chunk));The IPC channel is unaffected either way. All silent changes is how ordinary stdout and stderr travel between the child and the parent.
Every fork() child is a full, independent process with its own V8 instance and its own memory. Each one carries the memory cost of a whole Node process, far more than a callback running inside one process. Use fork() when you want that separation, whether for isolated memory, per-child memory limits, or a crash that does not take the parent down, or when you need one Node process exchanging messages with another. Worker threads, which share memory instead, are Chapter 15.
Synchronous Variants
The synchronous versions block your event loop until the child exits or is killed. There are three, spawnSync(), execSync(), and execFileSync().
import { execFileSync } from "node:child_process";
const version = execFileSync(process.execPath, ["-p", "process.version"], {
encoding: "utf8",
});
console.log(version.trim());The parent sits inside that call and does nothing else until it returns. Timers, network callbacks, stream events, and promise continuations all wait, because JavaScript control never leaves the synchronous call while the child is running.
In a startup script, an install step, local tooling, a one-shot CLI, or a build task, blocking the process is the intended behavior. Inside a server it is a problem. A request handler that blocks the one JavaScript thread stalls every other request until the child finishes.
Each sync API pairs with an async one.
spawn() -> spawnSync()
exec() -> execSync()
execFile() -> execFileSync()spawnSync() returns an object with status, signal, stdout, stderr, and error fields. execSync() and execFileSync() return stdout on success and throw on failure. All three accept options like cwd, env, input, timeout, and killSignal.
import { spawnSync } from "node:child_process";
const result = spawnSync(process.execPath, ["-e", "process.exit(3)"]);
console.log(result.status);
console.log(result.signal);That prints 3 and null. The child started and exited with code 3, a normal exit with no signal. Had the executable been missing, you would get an error on the result object instead.
The sync APIs buffer by default too. spawnSync() gives you stdout and stderr as buffers unless you pass an encoding. execSync() and execFileSync() return stdout directly, and on failure they throw an error object that still carries the child's output. Large output carries the same buffer risk here as it does with the async versions.
import { spawnSync } from "node:child_process";
const result = spawnSync(process.execPath, ["-p", "process.version"], {
encoding: "utf8",
});
console.log(result.stdout.trim());That works for a startup check. Inside a request handler it blocks every other piece of JavaScript until it returns.
Use the sync calls when you want to block. Use the async ones when the parent has other work to do.
Choosing the API
Start with the output.
streaming output -> spawn()
small buffered output -> execFile() or exec()
shell command string -> exec()
Node module with IPC -> fork()
blocking script flow -> spawnSync(), execSync(), execFileSync()Next, consider the form the command is in. A single command string needs a shell. A file plus an argument array is a direct launch. A path to a Node module is fork(). Passing one form to the API built for another is a frequent source of failure.
Use spawn() when output should stream.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "setInterval(() => console.log(Date.now()), 100)"]);
child.stdout.on("data", chunk => process.stdout.write(chunk));
setTimeout(() => child.kill(), 350);The parent sees output as it arrives. Use this for progress logs, long-running programs, and any output whose size you cannot bound in advance.
Use exec() when the command is naturally a shell command string and the output is small.
import { exec } from "node:child_process";
exec("node -p \"1 + 1\"", (error, stdout) => {
if (error) throw error;
console.log(stdout.trim());
});A shell parses the string. The callback receives the buffered output once it finishes.
Use execFile() when the target is a direct executable and the output is small.
import { execFile } from "node:child_process";
execFile(process.execPath, ["-p", "1 + 1"], (error, stdout) => {
if (error) throw error;
console.log(stdout.trim());
});No shell is involved by default. You still get the buffered output in the callback.
Use fork() when the child is a Node module and the parent needs a built-in IPC channel.
import { fork } from "node:child_process";
const child = fork(new URL("./worker-child.js", import.meta.url));
child.on("message", message => console.log(message));The child gets a full Node startup, its own V8 instance, and a ready-made channel for messages. Use fork() when you need both, an isolated Node process and a built-in channel between it and the parent.
Last, think about where failure will show up. Startup failures come through 'error'. A failure in the child's own logic shows up in the exit code, the signal, and whatever it wrote to stdout and stderr. Output that overflows the buffer is a maxBuffer error. Shell parsing only happens with exec() or { shell: true }. Direct argument passing is spawn() and execFile(). The failure mode points you to the field to check first.
Lifecycle Debugging
A child process can fail at four different points.
startup: executable, cwd, permissions, uid, gid, PATH
runtime: program logic writes stderr or exits manually
termination: exit code or signal reaches the parent
stdio close: output streams finish after process exitTo debug this, print state from each of those layers. Before launch, log the command, the args, the cwd, and the PATH you are passing. While you narrow the problem down, listen for all four of 'error', 'spawn', 'exit', and 'close', and capture stderr in every case. Keep shell commands and direct launches separate, since they fail for different reasons.
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["-e", "console.error('bad'); process.exit(2)"]);
child.on("spawn", () => console.log("spawn"));
child.stderr.on("data", chunk => process.stderr.write(chunk));
child.on("exit", (code, signal) => console.log("exit", code, signal));
child.on("close", (code, signal) => console.log("close", code, signal));The process starts, writes to stderr, exits with code 2, and then closes its stdio. The order they fire in identifies the failure stage. Startup succeeded, the program's own logic failed, 'exit' reported code 2, and 'close' marked the end of stream cleanup.
The same setup catches missing commands.
import { spawn } from "node:child_process";
const child = spawn("__missing__");
child.on("spawn", () => console.log("spawn"));
child.on("error", err => console.error("error", err.code));
child.on("close", (code, signal) => console.log("close", code, signal));Here 'spawn' never fires and 'error' reports the startup failure directly. The event that fired names the failure stage, which a generic log message read afterward does not.
subprocess.spawnargs is useful when the command was assembled piece by piece across helper functions.
import { spawn } from "node:child_process";
const args = ["-e", "console.log(process.argv.slice(1))"];
const child = spawn(process.execPath, args);
console.log(child.spawnargs);Print it before you blame the child's own code. A dropped flag, an extra quote, or the wrong working directory accounts for many of these failures on its own.
The Practical Default
When you start an external program, start with spawn(). It keeps the command and its arguments as separate fields, streams the output, and gives you clear lifecycle events. Move to execFile() when you want that same direct launch but with the output buffered for you. Use exec() when you want a shell to parse the string. Use fork() when the child is another Node module and you want the IPC channel that comes with it.
The synchronous variants are for programs where blocking is the intended behavior. Use them in scripts and build steps, and keep them out of latency-sensitive request handling.
Most child-process bugs come from passing the command in a form the chosen API does not expect, like handing a command string to an API that expects an argument array, giving a direct executable to a call that needs a shell, pointing a buffered API at unbounded output, or reading on 'exit' and losing bytes that arrive only on 'close'. Track three things, how the command is written, how the output comes back, and which lifecycle stage you are at, and the right API follows from them.