The single most common way I see child-process error handling go wrong is checking whether stderr has text in it. Do not do that. A command can write to stderr and still succeed (git, ffmpeg, and curl --verbose all chatter there on a perfectly good run). The signal that a process failed is its exit status, not whether it printed anything to stderr. For the synchronous calls that means checking result.status !== 0; for the async ones it means the 'error' event or a nonzero code in the 'close' callback. That one distinction fixes most of the broken try/catch blocks I get asked about.
The rest of this is the detail: which of the five child_process functions throws, which returns, and the three different failure modes you have to keep separate.
The four faces of child_process (plus fork)
child_process gives you the same job (run another program) in a few shapes, and they fail differently:
| Function | Sync/async | On failure | Buffers output? |
|---|---|---|---|
exec | async (callback) | passes an error to the callback | yes, whole output in memory |
execSync | sync | throws | yes, returns stdout |
spawn | async (events) | emits an 'error' event | no, streams |
spawnSync | sync | returns an object with status/error | yes |
fork | async (a spawn variant for Node scripts) | 'error' event + IPC channel | no, adds a message channel |
exec/execSync run your string through a shell (so |, >, && work, and so does shell injection if you interpolate user input). spawn/spawnSync run a binary directly with an args array and no shell. fork is spawn specialized for launching another Node script with an IPC channel attached. Pick spawn for anything streaming or long-running, execFile for a one-shot command whose output fits in memory, and reach for exec only when you genuinely need shell features.
Why execSync throws but spawnSync returns
This trips people up because the two look interchangeable. They are not.
execSync (and exec's callback) treat a nonzero exit as an error. A failed command throws, and you must wrap it:
const { execSync } = require("node:child_process");
try {
const out = execSync("git rev-parse HEAD", { encoding: "utf8" });
console.log(out.trim());
} catch (err) {
// err.status -> the exit code
// err.stderr -> what the command wrote to stderr
// err.message -> includes the failing command
console.error("git failed with code", err.status);
}spawnSync never throws for a nonzero exit. It hands you a result object and lets you decide:
const { spawnSync } = require("node:child_process");
const r = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" });
// r.status -> exit code (0 = success), or null if killed by a signal
// r.signal -> the signal that killed it, or null
// r.error -> an Error only if the process could not be spawned at all
// r.stdout / r.stderr -> the captured outputSo the same failure is a thrown exception with execSync and a quiet { status: 1 } with spawnSync. If you slap a try/catch around spawnSync expecting it to fire, nothing happens, and that is the "my try/catch isn't working" report I see most often. spawnSync only populates r.error when the spawn itself failed (binary missing, permission denied); a command that ran and exited nonzero is a normal return.
The fix: check status, not stderr
Here is the wrong version, the one that looks reasonable and ships bugs:
// WRONG: a clean command that prints to stderr is flagged as failed
const r = spawnSync("ffmpeg", ["-i", "in.mov", "out.mp4"]);
if (r.stderr && r.stderr.length) {
throw new Error("ffmpeg failed"); // ffmpeg ALWAYS writes to stderr
}ffmpeg writes its entire progress log to stderr on a successful encode. So does git clone (the "Cloning into…" lines), curl -v, npm warnings, and plenty more. Gating on stderr-has-text marks every one of those as a failure.
The correct check is the exit status:
function te_run(cmd, args) {
const r = spawnSync(cmd, args, { encoding: "utf8" });
// 1) Could the process even start? (binary missing, EACCES)
if (r.error) {
throw r.error;
}
// 2) Did it run but exit nonzero? THIS is "the command failed".
if (r.status !== 0) {
// Guard the toString: stderr can be null if the child never started cleanly.
const detail = String(r.stderr || "").trim();
throw new Error(`${cmd} exited with ${r.status}: ${detail}`);
}
return r.stdout;
}Two details in there matter. First, r.error and r.status are different failures (more on that below). Second, String(r.stderr || "") is not paranoia: if the spawn failed, r.stderr is null, and calling .toString() on it throws a second, confusing error on top of the first. Wrapping it in String(...) makes the guard safe whatever state the result is in.
Error vs nonzero exit vs stderr output are three different things
Keep these separate in your head and the API stops being confusing:
- Error (the process could not start). The binary is not on
PATH, thecwddoes not exist, or you lack execute permission. Sync:r.erroris set. Async: the'error'event fires. The command never ran, so there is no exit code. - Nonzero exit (the process ran and failed). The command started, did its thing, and returned a code other than
0. Sync:r.status. Async: thecodeargument of the'exit'/'close'event. This is "the command failed". - stderr output (the process said something). Diagnostics, progress, warnings. Completely independent of success. A program can exit
0with a full stderr log, or exit1having printed nothing.
Conflating the first two is the second-most-common bug; conflating the third with either is the most common.
Handle the async spawn 'error' event (this is ENOENT)
With async spawn, the failure that actually bites in production is ENOENT: the binary is not found. It does not throw, and it does not arrive as a nonzero exit. It comes through the 'error' event, and if you have not attached a listener it becomes an uncaught exception that crashes your process:
const { spawn } = require("node:child_process");
const child = spawn("git", ["status"]);
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk; });
// ENOENT (binary missing), EACCES (no execute bit), etc. land here.
child.on("error", (err) => {
console.error("could not start git:", err.code); // 'ENOENT'
});
// A command that DID run reports success/failure via the exit code here.
child.on("close", (code) => {
if (code !== 0) {
console.error(`git exited ${code}: ${stderr}`);
}
});'error' fires when the process could not be spawned. 'close' (or 'exit') fires when a process that did start has finished, and code is your status check. You need both: 'error' for "wrong binary name / not installed", 'close' for "ran but failed". Forgetting the 'error' listener is the classic way a missing dependency takes down an otherwise healthy Node service.
Promisify execFile for clean async/await
For a one-shot command whose output you want with await, execFile promisified is the tidiest option. It resolves with { stdout, stderr } and rejects on a nonzero exit, so a normal try/catch works the way you expect:
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const teExecFile = promisify(execFile);
async function te_git_head() {
try {
const { stdout } = await teExecFile("git", ["rev-parse", "HEAD"]);
return stdout.trim();
} catch (err) {
// err.code -> 'ENOENT' (string) if git could not be spawned,
// OR the numeric exit code if it ran and exited nonzero
// err.signal -> the signal name if the process was killed
// err.stderr -> captured stderr
throw new Error(`git rev-parse failed: ${err.code}`);
}
}Note execFile, not exec. execFile takes an args array and spawns the binary directly, with no shell in between. That is the safe default: there is no shell to interpret metacharacters, so an attacker-controlled value in the args array is passed as a literal argument, never as ; rm -rf /. If you reach for exec (or any shell string) and interpolate user input, you have a command-injection hole. Pass arguments as an array, and the whole class of bug goes away. If you genuinely need a pipeline, build it without interpolating untrusted input, or run the stages as separate spawn calls.
This is the same try/catch-around-await discipline covered in the JavaScript promises guide; the failure modes that bite here (a floating rejection from an un-awaited child, an error swallowed by a missing catch) are exactly the common promise mistakes that surface once you wrap a child process in async/await.
FAQ
See also
- Fix EADDRINUSE: port already in use in Node.js: another Node error that crashes the process if you don't listen for the right event.
- JavaScript promises guide: the try/catch-around-await pattern these promisified child-process calls depend on.
- Common JavaScript promise mistakes: floating rejections and swallowed errors, the traps you hit once a child process is wrapped in async/await.
Sources
Authoritative references this article was fact-checked against.





