| |

Node.js 63 🟢 Spawning Child Processes (exec, execFile, spawn, fork)

Node.js is single-threaded by design, but it is not limited to a single process. The child_process module lets you spawn additional processes from your Node.js application, whether they are system commands, external binaries, or other Node.js scripts. This is the mechanism behind build tools, CLI wrappers, worker pools, and any situation where the work is better done outside the main event loop.

The module provides four primary functions, and the differences between them are not cosmetic—they determine how the command is executed, what security risks you inherit, and what kinds of input are safe to pass. exec runs a command through a shell, which enables pipes and glob patterns but also exposes you to command injection if you pass unsanitized input . execFile runs an executable directly, bypassing the shell entirely . spawn is the most fundamental: it starts a process, returns a stream-based interface, and gives you full control over the child’s input and output . fork is a specialization of spawn for Node.js scripts, adding an IPC channel for message passing between parent and child .

This chapter covers each function, their trade-offs, and the patterns for using them safely.

Key point: exec and execFile buffer output and invoke a callback when the child exits; spawn streams output and returns a ChildProcess you can interact with; fork adds an IPC channel for message-based communication between Node.js processes. Only exec invokes a shell, which is why it is the only one with a built-in command injection risk.


Why multiple child process functions exist

The shell problem. Many useful commands are not single executables but shell expressions. ls -la | grep node is a pipeline, not a binary. rm *.log relies on the shell to expand the glob. To run these, you need a shell. exec provides that by spawning /bin/sh (or cmd.exe on Windows) and passing your command string to it . But the shell interprets metacharacters—|, >, &&, $(...), backticks—and if any part of that string comes from user input, an attacker can inject commands . execFile exists as the safer alternative: it runs the executable directly, with no shell to interpret anything.

The streaming problem. exec and execFile buffer the child’s output in memory and hand it to you in one piece when the process exits. For commands that produce large output, this is a problem: the default maxBuffer is 1 MB, and exceeding it kills the child . spawn solves this by exposing stdout and stderr as streams. You can process data as it arrives, write to stdin, and handle the child’s output in chunks. This is the right choice for long-running processes or large data streams.

The communication problem. Sometimes the child is another Node.js script, and you need bidirectional communication: the parent sends a task, the child processes it, the child sends back a result. spawn alone can do this through stdio pipes, but it is awkward. fork is a specialization of spawn that opens an IPC channel automatically, enabling child.send() and process.on('message') on both sides . This is the foundation for worker pools and task offloading in Node.js.

The synchronous problem. exec, execFile, and spawn are asynchronous. They return a ChildProcess object and emit events when the child exits. For scripting and startup configuration, sometimes you want a synchronous call that blocks until the child finishes. The execSync, execFileSync, and spawnSync variants exist for this . They block the event loop, so they are not appropriate for server code, but they simplify one-off scripts.


a. exec: shell execution with buffered output

exec spawns a shell and runs the command string inside it. It buffers stdout and stderr, and calls your callback with the accumulated output when the child exits .

const { exec } = require('child_process');

exec('ls -lh /usr', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error}`);
    return;
  }
  console.log(`stdout: ${stdout}`);
  console.error(`stderr: ${stderr}`);
});

The callback receives (error, stdout, stderr). On success, error is null. On failure, error is an Error with a code property (the exit code) and a signal property (the signal that terminated the process, if any) . Any exit code other than 0 is considered an error.

The options object supports several important settings. maxBuffer (default 1 MB in recent versions) sets the maximum bytes of stdout or stderr before the child is killed . timeout sets a kill timeout in milliseconds. cwd sets the working directory. env sets environment variables .

exec is convenient for short commands that produce limited output. But its shell invocation is a security liability. The Node.js documentation explicitly warns: “Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution” . Real vulnerabilities exist: a transcription library accepted an audioPath that was interpolated into a shell command, allowing $(...) injection to run arbitrary commands . An office converter accepted a fileName that broke out of quotes and executed injected commands . The pattern is always the same: user input reaches exec, and the shell interprets it as code.

If you must use exec with dynamic input, you must escape or validate it. But the safer path is almost always to use execFile or spawn instead.


b. execFile: direct execution without a shell

execFile runs the specified executable directly, without a shell. It takes the file path as the first argument and an array of arguments as the second. It buffers output and uses the same callback signature as exec .

const { execFile } = require('child_process');

execFile('node', ['--version'], (error, stdout, stderr) => {
  if (error) {
    throw error;
  }
  console.log(stdout);
});

The absence of a shell means that pipes, redirection, and glob expansion do not work. execFile('ls', ['*.js']) passes the literal string *.js to ls, which will try to find a file by that name rather than expanding it . But that is exactly the point: no metacharacter interpretation means no injection. The arguments are passed to the executable as discrete values.

Because there is no shell process to spawn, execFile is slightly more efficient than exec . The maxBuffer, timeout, cwd, and env options all work the same way. If you absolutely need shell behavior with execFile, you can set the shell option to true or to a specific shell path, but the documentation warns against doing so with unsanitized input.

For commands that produce modest output and do not need shell features, execFile is the safer default. It handles the common case of “run this binary with these arguments and give me the result” without the injection surface.


c. spawn: streaming and full control

spawn is the lowest-level and most flexible of the asynchronous functions. It starts a process and returns a ChildProcess instance immediately. You interact with the child through events and streams .

const { spawn } = require('child_process');

const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});

The stdout and stderr properties are readable streams. Data arrives in chunks as the child produces it. stdin is a writable stream you can use to send input to the child. The close event fires when the process has exited and all stdio streams have closed. The exit event fires earlier, when the process exits but streams may still have buffered data.

The stdio option controls how the child’s file descriptors are connected. The default is ['pipe', 'pipe', 'pipe'], which creates pipes for stdin, stdout, and stderr. Setting 'inherit' connects the child directly to the parent’s stdio, so the child’s output goes straight to the terminal. Setting 'ignore' connects the descriptor to /dev/null . You can also pass a stream object or an integer file descriptor to share a specific resource.

spawn does not invoke a shell by default, so it inherits the same injection safety as execFile. It is the right choice for long-running processes, for commands with large or streaming output, and for any situation where you need to interact with the child’s input or output as it runs.

The detached option, combined with unref(), lets a child process outlive its parent. This is used for launching background daemons from a CLI. On non-Windows platforms, a detached child becomes the leader of a new process group and session . Without unref(), the parent’s event loop keeps the child referenced, so the parent does not exit until the child does.


d. fork: Node.js child with IPC

fork is a specialization of spawn for Node.js scripts. It launches a new Node.js process running the specified module, and it opens an IPC channel between parent and child by default. This channel enables message passing: child.send() from the parent, and process.send() from the child, with 'message' events on both sides .

// parent.js
const { fork } = require('child_process');

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

child.on('message', (message) => {
  console.log('Parent received:', message);
});

child.send({ hello: 'world' });
// child.js
process.on('message', (message) => {
  console.log('Child received:', message);
  process.send({ foo: 'bar' });
});

Messages are serialized and parsed, so the received message may not be identical to the sent one (objects are deep-copied, and some types like functions and DOM objects cannot be sent). subprocess.send() returns false if the channel is closed or the message backlog is too large, allowing flow control through the callback .

The sendHandle argument can pass a TCP server or socket object to the child. This enables patterns where the parent creates a server and distributes connections to child processes for handling . This is Unix-only for dgram sockets, but TCP servers and sockets work on all platforms where Node.js supports the feature.

fork uses the same execPath as the parent by default, so the child runs the same Node.js binary. You can override this with the execPath option, and you can pass arguments to the child’s process through the args array. The silent option controls whether the child’s stdio is piped to the parent or inherited; by default, fork inherits stdio, which means the child’s console output appears in the parent’s terminal .

The primary use case for fork is offloading CPU-intensive work to a separate process without losing the ability to communicate. A worker pool where the parent dispatches tasks and the children return results is the canonical pattern.


Complete Example Session

The following session demonstrates all four functions, including the security difference between exec and execFile, and a fork-based worker.

// ============================================
// PART 1: IMPORTS
// ============================================
const { exec, execFile, spawn, fork } = require('child_process');
const path = require('path');

// ============================================
// PART 2: EXEC WITH SHELL PIPELINE
// ============================================
exec('echo "hello" | tr a-z A-Z', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error}`);
    return;
  }
  console.log(`exec stdout: ${stdout.trim()}`); // HELLO
});

// ============================================
// PART 3: EXECFILE WITHOUT SHELL
// ============================================
execFile('node', ['--version'], (error, stdout, stderr) => {
  if (error) {
    throw error;
  }
  console.log(`execFile stdout: ${stdout.trim()}`); // v20.x.x
});

// ============================================
// PART 4: EXECFILE REJECTS SHELL METACHARACTERS
// ============================================
// The following would run `ls` with a literal argument "*.js"
// The glob is NOT expanded because there is no shell.
// execFile('ls', ['*.js'], (err, stdout) => console.log(stdout));

// ============================================
// PART 5: SPAWN WITH STREAMING
// ============================================
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`spawn stdout chunk: ${data.toString().slice(0, 50)}...`);
});

ls.on('close', (code) => {
  console.log(`spawn exited with code ${code}`);
});

// ============================================
// PART 6: SPAWN WITH INHERITED STDIO
// ============================================
const echo = spawn('echo', ['inherited output'], {
  stdio: 'inherit'
});

// ============================================
// PART 7: FORK WITH MESSAGE PASSING
// ============================================
// child-worker.js (separate file):
// process.on('message', (msg) => {
//   if (msg.task === 'square') {
//     process.send({ result: msg.value * msg.value });
//   }
// });

const child = fork(path.join(__dirname, 'child-worker.js'));

child.on('message', (message) => {
  console.log(`fork result: ${message.result}`);
});

child.send({ task: 'square', value: 7 });

// ============================================
// PART 8: ERROR HANDLING FOR SPAWN
// ============================================
const bad = spawn('this-command-does-not-exist');

bad.on('error', (err) => {
  console.error(`spawn error: ${err.message}`);
});

// ============================================
// PART 9: KILLING A CHILD PROCESS
// ============================================
const longRunning = spawn('sleep', ['10']);

setTimeout(() => {
  longRunning.kill('SIGTERM');
  console.log('child killed');
}, 1000);

longRunning.on('exit', (code, signal) => {
  console.log(`exited with signal ${signal}`);
});

// ============================================
// PART 10: CLEANUP
// ============================================
child.on('exit', () => {
  console.log('fork child exited');
});

The ten parts cover exec with a shell pipeline, execFile without a shell, the metacharacter behavior difference, spawn with streaming, spawn with inherited stdio, fork with message passing, spawn error handling, killing a child process, and cleanup. What the session demonstrates is that each function exists for a specific reason, and the choice between them is determined by whether you need a shell, whether you need streaming, and whether you need bidirectional communication.


Quick Reference

Function Comparison

FunctionShellOutputReturnsUse Case
execYesBufferedChildProcessShell pipelines, small output
execFileNoBufferedChildProcessDirect binary, safe arguments
spawnNoStreamedChildProcessLarge output, long-running
forkNoStreamed + IPCChildProcessNode.js workers

Options Shared by exec and execFile

OptionDefaultMeaning
cwdnullWorking directory
envprocess.envEnvironment variables
timeout0Kill after N ms
maxBuffer1 MBMax stdout/stderr bytes
killSignalSIGTERMSignal for timeout
encodingutf8Output encoding

spawn stdio Values

ValueEffect
'pipe'Create pipe (default)
'inherit'Share parent’s stdio
'ignore'Connect to /dev/null
StreamShare a stream object
IntegerShare an open fd

fork Message API

SideMethodEvent
Parentchild.send(msg)child.on('message')
Childprocess.send(msg)process.on('message')

Best Practices

✅ Do This:

execFile('node', ['--version'], callback);                  // ✅ no shell
spawn('ls', ['-lh'], { stdio: 'inherit' });                 // ✅ streaming
const child = fork('./worker.js');                          // ✅ Node worker
child.on('error', (err) => log(err));                       // ✅ handle errors
child.kill('SIGTERM');                                      // ✅ signal, not kill

❌ Don’t Do This:

exec(`ls ${userInput}`, callback);                          // ❌ command injection
spawn('ls *.js', []);                                       // ❌ glob passed literally
exec('rm -rf /');                                           // ❌ dangerous shell
child.kill();                                               // ❌ default SIGTERM, not SIGKILL

Common Pitfalls

PitfallWhy It HappensFix
Command injectionexec with user inputUse execFile or spawn
Output truncatedmaxBuffer exceededIncrease limit or use spawn
Child output loststdio not configuredUse 'inherit' or listen to streams
spawn error not caughtNo 'error' listenerAdd child.on('error')
Fork child hangsNo exit handlingListen for 'exit' and 'close'
Parent exits before childNot unref’dUse detached + unref()

Real-World Examples

1. Run a CLI tool safely

execFile('git', ['status', '--short'], (err, stdout) => console.log(stdout));

2. Stream large output

const ffmpeg = spawn('ffmpeg', ['-i', 'input.mp4', 'output.mp4']);
ffmpeg.stderr.on('data', (d) => process.stderr.write(d));

3. Shell pipeline

exec('cat access.log | grep 404 | wc -l', (err, stdout) => console.log(stdout));

4. Fork a worker

const worker = fork('./image-worker.js');
worker.send({ path: '/tmp/image.png' });

5. Inherit stdio

spawn('npm', ['install'], { stdio: 'inherit' });

6. Kill on timeout

exec('slow-command', { timeout: 5000 }, callback);

7. Detached background process

const daemon = spawn('node', ['daemon.js'], { detached: true, stdio: 'ignore' });
daemon.unref();

8. Fork with arguments

fork('./worker.js', ['--id=1', '--queue=emails']);

9. Pass a server handle

child.send('server', server);

10. Check exit code

child.on('close', (code) => console.log(`exit code: ${code}`));

Visual

Shell vs No Shell

┌──────────────────────────────────────────────┐
│  exec('ls *.js')                             │
│         │                                    │
│         ▼                                    │
│  SHELL (/bin/sh)                             │
│  expands *.js to file names                  │
│  runs ls with those names                    │
│         │                                    │
│         ▼                                    │
│  ls output                                   │
│                                              │
│  execFile('ls', ['*.js'])                    │
│         │                                    │
│         ▼                                    │
│  NO SHELL                                    │
│  runs ls with literal argument "*.js"        │
│         │                                    │
│         ▼                                    │
│  ls: cannot access '*.js'                    │
└──────────────────────────────────────────────┘

Buffered vs Streamed

┌──────────────────────────────────────────────┐
│  exec / execFile                             │
│  ┌─────────┐                                 │
│  │ child   │──▶ [buffer] ──▶ callback        │
│  └─────────┘     (all output)                │
│                                              │
│  spawn                                       │
│  ┌─────────┐                                 │
│  │ child   │──▶ stdout stream ──▶ 'data'     │
│  └─────────┘──▶ stderr stream ──▶ 'data'     │
│  (chunks arrive as produced)                 │
└──────────────────────────────────────────────┘

fork IPC Channel

┌──────────────────────────────────────────────┐
│  PARENT                                      │
│  const child = fork('./worker.js')           │
│         │                                    │
│         ▼                                    │
│  child.send({ task: 'square', value: 7 })    │
│         │                                    │
│         ▼                                    │
│  IPC CHANNEL (automatic)                     │
│         │                                    │
│         ▼                                    │
│  CHILD                                       │
│  process.on('message', (msg) => {            │
│    process.send({ result: msg.value ** 2 }); │
│  })                                          │
│         │                                    │
│         ▼                                    │
│  child.on('message', (msg) => ...)           │
└──────────────────────────────────────────────┘

Security Comparison

┌──────────────────────────────────────────────┐
│  exec (WITH SHELL)                           │
│  exec(`convert ${userFile} out.pdf`)         │
│  userFile = "in.png; rm -rf /"               │
│         │                                    │
│         ▼                                    │
│  shell runs: convert in.png; rm -rf / out.pdf│
│  ❌ COMMAND INJECTION                        │
│                                              │
│  execFile (NO SHELL)                         │
│  execFile('convert', [userFile, 'out.pdf'])  │
│  userFile = "in.png; rm -rf /"               │
│         │                                    │
│         ▼                                    │
│  convert receives "in.png; rm -rf /" as      │
│  a literal file name                         │
│  ✅ SAFE (no shell to interpret it)          │
└──────────────────────────────────────────────┘

Summary

ItemValue
execShell execution, buffered output
execFileDirect execution, no shell, buffered
spawnDirect execution, streamed output
forkNode.js child with IPC channel
Shell metacharactersOnly interpreted by exec
Command injectionRisk only with exec + user input
maxBuffer1 MB default for exec/execFile
stdio option'pipe', 'inherit', 'ignore'
IPC messagessend() and 'message' event
Synchronous variantsexecSync, execFileSync, spawnSync

Key takeaways:

  • exec invokes a shell. This enables pipelines and globs but creates command injection risk if any input is user-controlled .
  • execFile runs a binary directly. No shell, no metacharacter interpretation, no injection. Use it for commands with dynamic arguments .
  • spawn streams output. It is the right choice for long-running processes or large output that would overflow maxBuffer .
  • fork adds an IPC channel. It is spawn for Node.js scripts, with send() and 'message' for bidirectional communication .
  • exec and execFile buffer output. The callback receives the complete stdout and stderr. spawn gives you streams.
  • The stdio option controls connectivity. 'pipe' (default), 'inherit' (share parent’s), 'ignore' (dev/null).
  • Always handle the 'error' event on spawn. A failed spawn emits 'error', not 'close'.
  • Use detached + unref() for background processes. Without unref(), the parent waits for the child.

Remember: The choice between exec, execFile, spawn, and fork is determined by three questions: do you need a shell, do you need streaming, and do you need IPC. exec is the only one that uses a shell, which makes it convenient for pipelines but dangerous with user input. execFile and spawn run binaries directly—execFile buffers output, spawn streams it. fork is spawn plus an IPC channel, designed for Node.js workers. When in doubt, prefer execFile or spawn over exec, and never pass unsanitized input to a function that invokes a shell.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!