Child Process

intermediate nodejs child_process shell ipc

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
spawnStreamed (stdout/stderr are streams)Long-running, big output, want to pipe
execBuffered into one string (callback)Quick command, small output (< 1MB)
execFileBuffered, no shellexec but safer (no shell injection)
forkIPC channelSpawning 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 error AND close events. A spawn error (binary not found) fires error, not close.
  • Sanitize args. If user input gets into a child process command, use execFile/spawn with an args array, never string concatenation into a shell.
  • Set timeouts. Hung children leak. Use the timeout option or kill them manually with child.kill('SIGTERM').
  • Pipe stdio carefully. By default child stdio is pipe. For fire-and-forget background jobs, use stdio: 'ignore' and detached: true with child.unref() so the parent can exit.
  • Don’t block the event loop waiting for output. execSync exists. Don’t use it in a request handler.