Skip to content
Node.js nd process 5 min read

Running Commands with child_process

Node.js can shell out to external programs through the built-in node:child_process module. This is how you run Git, invoke ffmpeg, query the operating system, or glue together command-line tools from inside a JavaScript process. The exec family of functions is the simplest entry point: it runs a command, buffers its output, and hands you back the result in one shot. Knowing when to use exec versus execFile, and understanding the shell-injection risks involved, is essential for writing safe automation.

How exec works

exec spawns a shell (/bin/sh on Unix, cmd.exe on Windows) and runs the command string inside it. Because a full shell is involved, you get features like pipes, globbing, environment-variable expansion, and && chaining for free. The entire stdout and stderr streams are collected into memory and delivered when the process exits.

The callback-based API receives (error, stdout, stderr). In modern code, promisify it so it fits cleanly into async/await.

import { exec } from "node:child_process";
import { promisify } from "node:util";

const execAsync = promisify(exec);

const { stdout, stderr } = await execAsync("git rev-parse --abbrev-ref HEAD");
console.log("Branch:", stdout.trim());
if (stderr) console.error(stderr.trim());

Output:

Branch: main

If the command exits with a non-zero status, the promise rejects (or the callback receives an error). The error object carries useful diagnostic fields.

try {
  await execAsync("ls /does/not/exist");
} catch (err) {
  console.error("code:", err.code);     // exit code
  console.error("signal:", err.signal); // signal if killed
  console.error(err.stderr.trim());
}

Output:

code: 2
signal: null
ls: cannot access '/does/not/exist': No such file or directory

Node also exposes child_process.execSync, a blocking variant that returns stdout as a Buffer (or string with encoding) and throws on failure. It blocks the event loop, so reserve it for startup scripts and CLI tooling — never use it inside a request handler.

execFile: skipping the shell

execFile runs a binary directly, passing arguments as an array. No shell is spawned, so there is no shell parsing, no globbing, and — critically — no shell-injection surface. It is faster and safer whenever you don’t actually need shell features.

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// Arguments are passed verbatim — no quoting or escaping needed.
const { stdout } = await execFileAsync("node", ["--version"]);
console.log(stdout.trim());

Output:

v22.11.0

Because arguments are an array rather than a single string, user-supplied values can never be reinterpreted as additional commands. This is the key reason to prefer execFile for anything touching untrusted input.

exec vs execFile vs spawn

These three functions trade off convenience, safety, and streaming behavior.

FunctionSpawns a shell?Output handlingBest for
execYesBuffered (whole output in memory)Quick commands needing pipes/globs
execFileNoBufferedRunning a single binary with fixed args
spawnNo (unless shell: true)StreamedLong-running or large-output processes

For commands that emit large or continuous output, use spawn instead — it gives you streaming stdout/stderr rather than buffering everything.

Buffer limits and options

Both exec and execFile accept an options object. The most important option is maxBuffer: the largest amount of data (in bytes) allowed on stdout or stderr. The default is 1 MB (1024 × 1024). Exceed it and the child is killed with an ERR_CHILD_PROCESS_STDOUT_MAXBUFFER error.

OptionTypeDescription
cwdstringWorking directory for the child process
envobjectEnvironment variables (defaults to process.env)
timeoutnumberMilliseconds before the child is killed
maxBuffernumberMax bytes of stdout/stderr (default 1024 * 1024)
encodingstringOutput encoding; "buffer" returns raw Buffers
shellstringShell to use (exec); path to a custom shell
signalAbortSignalAborts the process when the signal fires
const { stdout } = await execAsync("find . -name '*.log'", {
  cwd: "/var/app",
  timeout: 5000,
  maxBuffer: 10 * 1024 * 1024, // 10 MB
});
console.log(stdout.split("\n").filter(Boolean).length, "log files");

Output:

42 log files

You can also cancel a running command with an AbortController, which is the modern way to enforce cancellation alongside or instead of timeout.

const controller = new AbortController();
setTimeout(() => controller.abort(), 2000);

try {
  await execAsync("sleep 10", { signal: controller.signal });
} catch (err) {
  console.error(err.name); // AbortError
}

Shell injection: the central security risk

The convenience of exec is also its danger. Because it concatenates your command into a shell string, interpolating untrusted input directly is a serious vulnerability. An attacker who controls part of the string can append their own commands.

// DANGEROUS — never do this with user input
const filename = userInput; // e.g. "report.txt; rm -rf ~"
await execAsync(`cat ${filename}`); // runs rm -rf ~ too

The fix is to avoid the shell entirely with execFile, passing the untrusted value as a discrete argument.

// SAFE — the filename can never break out into a new command
await execFileAsync("cat", [userInput]);

Treat every interpolated value in an exec string as a potential injection. If any part comes from a request body, query string, filename, or other external source, switch to execFile (or spawn) with an argument array.

If you genuinely must build a shell string with dynamic data, validate it against a strict allowlist (for example, a regex permitting only [\w.-]+) before it ever reaches exec.

Best practices

  • Prefer execFile over exec whenever you don’t need shell features — it is both faster and immune to injection.
  • Never interpolate untrusted input into an exec command string; pass it as an array argument to execFile instead.
  • Promisify the callback APIs with node:util so errors surface naturally through try/catch.
  • Set an explicit maxBuffer for commands that may produce large output, and switch to spawn for streaming or unbounded output.
  • Always specify timeout or an AbortSignal for commands that could hang, so a stuck child can’t leak resources.
  • Reserve the synchronous execSync/execFileSync variants for CLI scripts and startup code — never call them inside server request handlers.
  • Inspect err.code, err.signal, and err.stderr on failure to distinguish a non-zero exit from a signal-killed or buffer-overflow termination.
Last updated June 14, 2026
Was this helpful?