child_process
child_process runs external programs from Node - prefer spawn with explicit argument arrays, understand stdio modes, and avoid shell injection when wrapping CLI tools.
Recipe
import { spawn } from 'node:child_process';
const child = spawn('git', ['rev-parse', 'HEAD'], { stdio: ['ignore', 'pipe', 'pipe'] });import { promisify } from 'node:util';
import { execFile } from 'node:child_process';
const execFileAsync = promisify(execFile);
const { stdout } = await execFileAsync('node', ['--version']);When to reach for this:
- Image processing via
ffmpegCLI - Git operations in deployment scripts
- Running language formatters in pre-commit hooks
- Isolating untrusted code in separate OS process (with sandbox beyond Node)
Working Example
import { spawn } from 'node:child_process';
import { once } from 'node:events';
async function runGitHash(): Promise<string> {
const child = spawn('git', ['rev-parse', 'HEAD'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
const [code] = await once(child, 'exit');
if (code !== 0) throw new Error(`git exited ${code}`);
return stdout.trim();
}
// Dangerous - do not do this with user input:
// spawn(`git rev-parse ${userBranch}`, { shell: true });import { pipeline } from 'node:stream/promises';
import { spawn } from 'node:child_process';
import { createWriteStream } from 'node:fs';
async function compressWithGzip(input: string, output: string): Promise<void> {
const gzip = spawn('gzip', ['-c', input], { stdio: ['ignore', 'pipe', 'inherit'] });
await pipeline(gzip.stdout, createWriteStream(output));
const code = await new Promise<number>((res) => gzip.on('exit', res));
if (code !== 0) throw new Error(`gzip failed ${code}`);
}What this demonstrates:
- Argument array passes literals to
execvewithout shell interpretation - Collect stdout via stream chunks - not
execbuffer limits for large output - Pipe child stdout to file with
pipelinefor backpressure shell: trueonly for trusted fixed commands - never with user strings
Deep Dive
How It Works
spawn- streams stdio, returns immediately,exitevent with code.exec- buffers stdout/stderr, invokes shell by default - injection risk.execFile- no shell, buffered output withmaxBufferdefault 1MB.fork- special Node child with IPC channel - legacy pattern before worker_threads.
API Comparison
| API | Shell | Output | Use |
|---|---|---|---|
| spawn | No (default) | Stream | Long output, pipes |
| exec | Yes (default) | Buffered | Small trusted commands |
| execFile | No | Buffered | Small args array |
| fork | No | IPC | Node-only workers (legacy) |
TypeScript Notes
import type { ChildProcess } from 'node:child_process';
export function killProcessTree(child: ChildProcess): void {
if (child.pid) process.kill(-child.pid, 'SIGTERM');
}Platform-specific - Linux process groups need detached: true on spawn.
Gotchas
shell: true+ user input - command injection. Fix:spawn(cmd, [arg1, arg2])without shell.execmaxBuffer exceeded - throws on large stdout. Fix:spawnwith streams.- Not reaping zombies - always attach
exithandler or await exit in long-running supervisors. - Inherited stdio in daemons - child holds TTY open. Fix:
stdio: 'ignore'for background jobs. - Windows path quoting - still use array form; avoid manual quote escaping in shell strings.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| worker_threads | CPU JS in same runtime | Need separate binary or sandbox OS process |
| Pure Node library | ffmpeg wasm, sharp vs CLI | Mature CLI already scripted |
| Container exec | K8s job per task | Simple local script |
node:child_process test mocks | Unit tests | Production orchestration |
FAQs
spawn vs exec?
spawn for streaming and safety; exec for quick small trusted shell one-liners in dev scripts only.
What is stdio inherit?
Child shares parent console - good for CLI tools showing live output to terminal.
How to pass env vars?
spawn(cmd, args, { env: { ...process.env, FOO: 'bar' } }).
Can I pipe to child stdin?
Yes - stdio: ['pipe', 'pipe', 'pipe'] and write to child.stdin.
What exit code means signal?
code null and signal set on kill - handle SIGTERM in child scripts.
Timeout long commands?
setTimeout + child.kill('SIGKILL') with cleanup - or use timers/promises + AbortSignal patterns in wrappers.
Is fork deprecated?
Not removed - prefer worker_threads for CPU; fork for Node IPC legacy code.
Security review focus?
Search shell: true and string-concatenated commands in PRs.
stderr handling?
Pipe stderr to logger - stdio: ['ignore', 'pipe', 'pipe'] and tag child stderr lines.
Cluster vs spawn?
cluster forks Node workers; spawn runs arbitrary executables.
CI git commands?
execFile('git', ['diff', '--name-only']) - safe and deterministic.
Windows spawn notes?
.cmd files may need shell: true - prefer node/npm with explicit paths documented for team.
Related
- worker_threads - in-process CPU
- Process Signals & Shutdown - killing children cleanly
- Processes Best Practices - security checklist
- Callbacks vs Promises Migration - promisify execFile
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.