TechEarl

Catching Errors from Node.js child_process (spawn, exec, execSync)

Why a try/catch around execSync doesn't save you, why spawnSync returns instead of throws, and the one check that actually tells you a child process failed: the exit status, not whether stderr has text.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
Handle errors from Node.js child_process: check the exit status not stderr text, catch the spawn 'error' event for ENOENT, and tell error from nonzero-exit from stderr output.

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:

FunctionSync/asyncOn failureBuffers output?
execasync (callback)passes an error to the callbackyes, whole output in memory
execSyncsyncthrowsyes, returns stdout
spawnasync (events)emits an 'error' eventno, streams
spawnSyncsyncreturns an object with status/erroryes
forkasync (a spawn variant for Node scripts)'error' event + IPC channelno, 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:

javascript
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:

javascript
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 output

So 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:

javascript
// 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:

javascript
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, the cwd does not exist, or you lack execute permission. Sync: r.error is 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: the code argument 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 0 with a full stderr log, or exit 1 having 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:

javascript
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:

javascript
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

Sources

Authoritative references this article was fact-checked against.

Tagsnodejschild_processspawnexecSyncspawnSyncerror handlingENOENT

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store JSON in PostgreSQL: json vs jsonb

PostgreSQL has two JSON types. json keeps an exact text copy and reparses on every read; jsonb stores a decomposed binary format you can index with GIN. When to use each, the operators, and a worked products schema with a containment index.