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 returnsstdoutas a Buffer (or string withencoding) 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.
| Function | Spawns a shell? | Output handling | Best for |
|---|---|---|---|
exec | Yes | Buffered (whole output in memory) | Quick commands needing pipes/globs |
execFile | No | Buffered | Running a single binary with fixed args |
spawn | No (unless shell: true) | Streamed | Long-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.
| Option | Type | Description |
|---|---|---|
cwd | string | Working directory for the child process |
env | object | Environment variables (defaults to process.env) |
timeout | number | Milliseconds before the child is killed |
maxBuffer | number | Max bytes of stdout/stderr (default 1024 * 1024) |
encoding | string | Output encoding; "buffer" returns raw Buffers |
shell | string | Shell to use (exec); path to a custom shell |
signal | AbortSignal | Aborts 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
execstring as a potential injection. If any part comes from a request body, query string, filename, or other external source, switch toexecFile(orspawn) 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
execFileoverexecwhenever you don’t need shell features — it is both faster and immune to injection. - Never interpolate untrusted input into an
execcommand string; pass it as an array argument toexecFileinstead. - Promisify the callback APIs with
node:utilso errors surface naturally throughtry/catch. - Set an explicit
maxBufferfor commands that may produce large output, and switch tospawnfor streaming or unbounded output. - Always specify
timeoutor anAbortSignalfor commands that could hang, so a stuck child can’t leak resources. - Reserve the synchronous
execSync/execFileSyncvariants for CLI scripts and startup code — never call them inside server request handlers. - Inspect
err.code,err.signal, anderr.stderron failure to distinguish a non-zero exit from a signal-killed or buffer-overflow termination.