Sometimes we need Node to do something Node can’t do directly — run ffmpeg, call git, execute a Python script, shell out to imagemagick. That’s child_process.
It gives us four ways to spawn an external process: spawn, exec, execFile, and fork. They all start a subprocess. The only difference is how output is delivered and what the child is.
spawn vs exec vs fork — pick the right one
| Method | Output | Use when |
|---|---|---|
spawn | Streamed (stdout/stderr are streams) | Long-running, big output, want to pipe |
exec | Buffered into one string (callback) | Quick command, small output (< 1MB) |
execFile | Buffered, no shell | exec but safer (no shell injection) |
fork | IPC channel | Spawning another Node.js script |
spawn — the workhorse
spawn returns a child process with stdout/stderr as readable streams. Use this for anything that produces a lot of output or runs a while.
import { spawn } from 'node:child_process';
const ffmpeg = spawn('ffmpeg', ['-i', 'input.mp4', '-c:v', 'libx264', 'out.mp4']);
ffmpeg.stdout.on('data', (chunk) => {
console.log('stdout:', chunk.toString());
});
ffmpeg.stderr.on('data', (chunk) => {
// ffmpeg writes progress to stderr, weirdly
process.stderr.write(chunk);
});
ffmpeg.on('close', (code) => {
if (code === 0) console.log('done');
else console.error(`ffmpeg exited with code ${code}`);
});
Because output is streamed, memory stays flat even if ffmpeg runs for an hour and prints megabytes.
exec — convenient but dangerous
exec runs the command through a shell (/bin/sh -c ...) and buffers all output into one string. Easy for one-liners.
import { exec } from 'node:child_process';
exec('git log --oneline -5', (err, stdout, stderr) => {
if (err) return console.error(err);
console.log(stdout);
});
The shell convenience comes with a catch: shell injection. Never do this:
// BAD — user controls filename, can inject `; rm -rf /`
exec(`cat ${userInput}`, callback);
Use execFile or spawn with an args array — no shell involved, no injection.
import { execFile } from 'node:child_process';
// Safe. userInput is an argv element, not interpreted by shell.
execFile('cat', [userInput], callback);
Also: exec has a default maxBuffer of 1MB. If the command prints more, it errors. Bump it or switch to spawn.
fork — Node-to-Node with IPC
fork is a special case of spawn for launching another Node script. It sets up an IPC channel so parent and child can send() messages to each other.
// parent.js
import { fork } from 'node:child_process';
const worker = fork('./worker.js');
worker.send({ task: 'resize', file: 'photo.jpg' });
worker.on('message', (msg) => {
console.log('worker said:', msg);
});
// worker.js
process.on('message', async (msg) => {
// do heavy work
const result = await processImage(msg.file);
process.send({ done: true, result });
});
Use fork when we want to offload CPU work to another process without the complexity of cluster. (Worker threads are usually a better fit for pure-CPU work — fork shines when the child needs its own memory space, e.g. running untrusted code or a separate Node version.)
Production checklist
- Always handle
errorANDcloseevents. A spawn error (binary not found) fireserror, notclose. - Sanitize args. If user input gets into a child process command, use
execFile/spawnwith an args array, never string concatenation into a shell. - Set timeouts. Hung children leak. Use the
timeoutoption or kill them manually withchild.kill('SIGTERM'). - Pipe stdio carefully. By default child stdio is
pipe. For fire-and-forget background jobs, usestdio: 'ignore'anddetached: truewithchild.unref()so the parent can exit. - Don’t block the event loop waiting for output.
execSyncexists. Don’t use it in a request handler.