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

Because spawnSync does not throw on a nonzero exit, it returns. A failed command gives you a result object with status set to a nonzero number; nothing is thrown. Check result.status !== 0 instead of relying on the catch. spawnSync only sets result.error (and even then does not throw) when the process could not be started at all, such as a missing binary. If you want throw-on-failure behavior, use execSync or promisified execFile.

Check the exit status, not the stderr output. For spawnSync it is result.status (0 means success). For async spawn it is the code in the 'close' or 'exit' event. For execSync and promisified execFile, a nonzero exit throws/rejects. Do not test whether stderr has text: many programs write to stderr on a perfectly successful run.

The binary you asked to run was not found. The command name is misspelled, the program is not installed, or it is not on the PATH of the Node process. ENOENT is not a nonzero exit (the command never started), so it arrives as result.error from spawnSync or on the 'error' event of async spawn. If you have no 'error' listener, it becomes an uncaught exception and crashes your app. On Windows, note that spawn does not auto-resolve .bat/.cmd the way the shell does.

Prefer execFile (or spawn). It runs the binary directly with an arguments array and no shell, so shell metacharacters in your arguments are passed literally and command injection is not possible. Use exec only when you actually need shell features like pipes or globbing, and never interpolate untrusted input into the command string when you do.

They describe two different failures. error is set when the process could not be spawned at all (missing binary, permission denied), in which case it never ran and there is no exit code. status is the exit code of a process that did run; nonzero means it ran and failed. Check error first, then status. When you read stderr, guard it with String(result.stderr || "") because it can be null when the spawn itself failed.

See also

Sources

Authoritative references this article was fact-checked against.

Tagsnodejschild_processspawnexecSyncspawnSyncerror handlingENOENT

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Seventy-five AI jokes about CEOs after one ChatGPT demo, CTOs holding the GPU bill, developers reviewing hallucinated code, prompt engineers, AI ethics meetings, and corporate AI hype.

AI Jokes About CEOs, CTOs, and the Hype Cycle

Seventy-five AI jokes about the CEO who saw one ChatGPT demo, the CTO holding the GPU bill, the developers reviewing hallucinated code, and the whole company pretending to understand embeddings.

How to store JSON in PostgreSQL: the json type's exact text copy versus the jsonb decomposed binary format, the containment and key-existence operators, and indexing a jsonb column with a GIN index.

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.