NodeJSspawn, exec, execFile & fork

spawn, exec, execFile & fork

The child_process module offers four ways to start a child, and choosing the right one matters for performance, safety, and ergonomics. The core axes are: does it stream output or buffer it all into memory? does it run through a shell (convenient but injectable) or execute a binary directly (safe)? and does it set up an IPC channel for message passing? This page compares all four, shows each in action, and gives a clear decision rule.

The comparison

Output

Shell?

IPC?

Best for

spawn

Streamed

No (default)

No

Long-running, large/continuous output

exec

Buffered

Yes

No

Short shell commands, small output

execFile

Buffered

No

No

Running a binary safely with args

fork

Streamed

No

Yes

Spawning Node scripts that message back

`spawn` streams (low memory), `exec` buffers (convenient but bounded), `execFile` is the safe `exec`, `fork` adds IPC
All four ultimately call `spawn` under the hood with different defaults. `spawn` gives you the child's stdio as **streams** — ideal for large or open-ended output because nothing accumulates in memory. `exec` runs the command in a **shell** and **buffers** all output, handing it to a callback once the process finishes — convenient for quick commands but memory-bounded and shell-injectable. `execFile` is like `exec` but runs a binary **directly without a shell** (safer). `fork` is a special `spawn` for Node scripts that wires up an **IPC channel** so parent and child can exchange structured messages.
spawn — stream a long-running command

JS
import { spawn } from 'node:child_process'

// Transcode a video — output streams, so memory stays flat even for huge files:
const ff = spawn('ffmpeg', ['-i', 'input.mov', '-c:v', 'libx264', 'output.mp4'])

ff.stderr.on('data', (d) => process.stdout.write(d))   // ffmpeg logs progress to stderr
ff.on('close', (code) => console.log(code === 0 ? 'done' : `failed (${code})`))
Use `spawn` when output is large or continuous — buffering it with `exec` could exhaust memory
`spawn` shines when the child produces a lot of output or runs for a long time: because you consume `stdout`/`stderr` as **streams**, data flows through chunk-by-chunk and memory usage stays constant regardless of total output size. This is exactly what you want for media transcoding, tailing logs, large file processing, or any process whose output you'd never want to hold entirely in RAM. By default `spawn` uses no shell, so passing arguments as an array is also the injection-safe choice.
exec — buffer the output of a short command

JS
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
const execAsync = promisify(exec)

// Convenient for short commands whose output fits comfortably in memory:
const { stdout } = await execAsync('git rev-parse --short HEAD')
console.log('commit:', stdout.trim())

// You CAN cap the buffer to avoid surprises:
exec('some-command', { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => { /* ... */ })
`exec` buffers ALL output — output larger than `maxBuffer` (default ~1MB) kills the child with an error
`exec` collects the *entire* stdout and stderr into memory before calling you back, capped by `maxBuffer` (default 1MB). If the command produces more than that — a verbose build, a big file dump — the child is **killed** and you get an `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` error, often surprising people whose command "worked in the terminal". Use `exec` only for commands with small, bounded output; for anything potentially large, use `spawn` and stream. And remember `exec` runs a shell — never interpolate untrusted input into its command string ([command injection](/nodejs/child-processes)).
execFile — run a binary safely

JS
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)

// No shell: 'userFile' is passed as a single literal argument — injection-proof:
const { stdout } = await execFileAsync('identify', ['-format', '%wx%h', userFile])
console.log('dimensions:', stdout)   // e.g. "1920x1080"
`execFile` is the safe default for running an executable with user-supplied arguments
`execFile` runs a named executable directly, passing arguments as an **array** with no shell in between — so shell metacharacters in the arguments are inert and there's nothing to inject. It still buffers output (same `maxBuffer` caveat as `exec`), but it removes the shell-injection risk, making it the right choice when any argument comes from user input. The rule of thumb: prefer `execFile` (or `spawn`) over `exec` whenever you're invoking a specific binary, and reserve `exec` for trusted, fixed shell strings where you actually need shell features like pipes or globbing.
fork — spawn a Node script with IPC

parent.js

JS
import { fork } from 'node:child_process'

const child = fork('./worker.js')

// Send a structured message (objects are serialized automatically):
child.send({ cmd: 'sum', numbers: [1, 2, 3, 4, 5] })

// Receive the child's reply over the IPC channel:
child.on('message', (msg) => {
  console.log('result from child:', msg.result)   // 15
  child.disconnect()
})

worker.js

JS
process.on('message', (msg) => {
  if (msg.cmd === 'sum') {
    const result = msg.numbers.reduce((a, b) => a + b, 0)
    process.send({ result })   // send the answer back to the parent
  }
})
`fork` gives parent↔child message passing — but it's still a separate process, not shared memory
`fork` is purpose-built for spawning *Node* scripts: it launches a new Node process running your file and automatically establishes an **IPC channel**, so `child.send(obj)` in the parent and `process.on('message')` in the child (and vice versa) exchange structured, JSON-serializable messages. It's perfect for offloading discrete jobs to a separate Node process that reports results back. But note: each `fork` boots a fresh V8 instance (notable startup cost and memory), and the processes are **fully isolated** — they do *not* share memory. When you need shared-memory parallelism for CPU work, [worker threads](/nodejs/worker-threads) are lighter and faster than forking.
The decision rule
  • Large or continuous output?spawn (stream it).

  • Short command, small output, trusted string?exec (convenient).

  • Running a binary with user-supplied args?execFile (no shell, no injection).

  • Spawning a Node script that must message back?fork (built-in IPC).

  • Just need parallel CPU compute in-process? → not child_process at all — use worker threads.

Next
Scale a server across every CPU core: [The cluster Module](/nodejs/cluster-module).