Skip to main content
Technology & EngineeringNodejs Patterns198 lines

Child Processes

Child process management patterns for spawning, communicating with, and controlling external processes

Quick Summary18 lines
You are an expert in Node.js child process management for spawning, communicating with, and controlling external processes safely.

## Key Points

- **`spawn(command, args)`** — streams stdio, no shell by default, best for long-running processes or large output.
- **`exec(command)`** — runs in a shell, buffers all output, has a `maxBuffer` limit. Convenient for short commands.
- **`execFile(file, args)`** — like `exec` but bypasses the shell, safer for untrusted input.
- **`fork(modulePath)`** — spawns a new Node.js process with an IPC channel for `send()`/`on('message')` communication.
- Use `execFile` or `spawn` instead of `exec` when arguments come from user input — they avoid shell interpretation and injection attacks.
- Always handle the `'error'` event on spawned processes — it fires when the command is not found or cannot be executed.
- Set `timeout` and `maxBuffer` on `exec`/`execFile` to prevent runaway processes and memory exhaustion.
- Use `{ stdio: 'inherit' }` for CLI tools that need to pass through terminal formatting and interactivity.
- Forward `SIGTERM`/`SIGINT` to child processes in your shutdown handler to avoid orphaned processes.
- **Shell injection** — using `exec()` with unsanitized user input allows arbitrary command execution. Always use `execFile()` or `spawn()` with argument arrays.
- **Ignoring `maxBuffer`** — `exec()` defaults to 1 MB of buffered output; exceeding it kills the process silently. Increase the limit or use `spawn()` for large output.
- **Zombie processes** — not handling child exit events or not killing children on parent exit leaves orphaned processes consuming resources.
skilldb get nodejs-patterns-skills/Child ProcessesFull skill: 198 lines
Paste into your CLAUDE.md or agent config

Child Processes — Node.js Patterns

You are an expert in Node.js child process management for spawning, communicating with, and controlling external processes safely.

Core Philosophy

Overview

The node:child_process module lets Node.js applications run external commands, scripts, and executables. It provides four methods — spawn, exec, execFile, and fork — each suited to different use cases. Correct usage requires understanding stdio piping, signal handling, shell injection risks, and process lifecycle management.

Core Concepts

spawn vs exec vs execFile vs fork

  • spawn(command, args) — streams stdio, no shell by default, best for long-running processes or large output.
  • exec(command) — runs in a shell, buffers all output, has a maxBuffer limit. Convenient for short commands.
  • execFile(file, args) — like exec but bypasses the shell, safer for untrusted input.
  • fork(modulePath) — spawns a new Node.js process with an IPC channel for send()/on('message') communication.

stdio Configuration

The stdio option controls how the child's stdin, stdout, and stderr connect to the parent: 'pipe' (default), 'inherit', 'ignore', or a file descriptor.

IPC Channel

fork() and spawn() with { stdio: ['pipe','pipe','pipe','ipc'] } establish an Inter-Process Communication channel for exchanging serialized JavaScript objects.

Implementation Patterns

Running a command and capturing output

const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execFileAsync = promisify(execFile);

async function getGitHash() {
  const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD']);
  return stdout.trim();
}

Streaming output from a long-running process

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

function tailLog(filePath, onLine) {
  const child = spawn('tail', ['-f', filePath]);

  let buffer = '';
  child.stdout.on('data', (chunk) => {
    buffer += chunk.toString();
    const lines = buffer.split('\n');
    buffer = lines.pop();
    lines.forEach(onLine);
  });

  child.on('error', (err) => {
    console.error('Failed to start tail:', err.message);
  });

  return () => child.kill('SIGTERM');
}

IPC communication with fork

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

const worker = fork('./compute.js');

worker.send({ type: 'calculate', payload: { n: 1_000_000 } });

worker.on('message', (msg) => {
  if (msg.type === 'result') {
    console.log('Result:', msg.value);
    worker.disconnect();
  }
});

// compute.js
process.on('message', (msg) => {
  if (msg.type === 'calculate') {
    const result = heavyComputation(msg.payload.n);
    process.send({ type: 'result', value: result });
  }
});

Process pool with AbortController

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

async function runWithTimeout(cmd, args, timeoutMs) {
  const ac = new AbortController();

  try {
    const { stdout } = await new Promise((resolve, reject) => {
      const child = execFile(cmd, args, {
        signal: ac.signal,
        timeout: timeoutMs,
      }, (err, stdout, stderr) => {
        if (err) return reject(err);
        resolve({ stdout, stderr });
      });
    });
    return stdout;
  } catch (err) {
    if (err.killed) {
      throw new Error(`Process ${cmd} timed out after ${timeoutMs}ms`);
    }
    throw err;
  }
}

Safe command execution without shell injection

const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execFileAsync = promisify(execFile);

// DANGEROUS — shell injection vulnerability
// exec(`grep ${userInput} /var/log/app.log`);

// SAFE — arguments are passed as array, no shell involved
async function searchLogs(pattern) {
  const { stdout } = await execFileAsync('grep', ['-F', pattern, '/var/log/app.log']);
  return stdout.split('\n').filter(Boolean);
}

Graceful child process shutdown

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

function startService(command, args) {
  const child = spawn(command, args, { stdio: 'inherit' });

  async function shutdown() {
    return new Promise((resolve) => {
      child.once('exit', resolve);

      child.kill('SIGTERM');

      // Force kill after timeout
      const timer = setTimeout(() => {
        child.kill('SIGKILL');
      }, 5000);
      timer.unref();
    });
  }

  process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
  process.on('SIGINT', () => shutdown().then(() => process.exit(0)));

  return { child, shutdown };
}

Best Practices

  • Use execFile or spawn instead of exec when arguments come from user input — they avoid shell interpretation and injection attacks.
  • Always handle the 'error' event on spawned processes — it fires when the command is not found or cannot be executed.
  • Set timeout and maxBuffer on exec/execFile to prevent runaway processes and memory exhaustion.
  • Use { stdio: 'inherit' } for CLI tools that need to pass through terminal formatting and interactivity.
  • Forward SIGTERM/SIGINT to child processes in your shutdown handler to avoid orphaned processes.

Common Pitfalls

  • Shell injection — using exec() with unsanitized user input allows arbitrary command execution. Always use execFile() or spawn() with argument arrays.
  • Ignoring maxBufferexec() defaults to 1 MB of buffered output; exceeding it kills the process silently. Increase the limit or use spawn() for large output.
  • Zombie processes — not handling child exit events or not killing children on parent exit leaves orphaned processes consuming resources.
  • Assuming cross-platform commandsls, grep, cat do not exist on Windows. Use Node.js APIs or cross-platform tools when portability matters.
  • Not checking exit codes — a process can produce output on stdout and still fail. Always check the exit code in addition to reading output.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add nodejs-patterns-skills

Get CLI access →