πŸš€AdvancedNode.js Concurrency

Child Processes

Spawn OS processes and shell commands from Node.js using exec, spawn, and fork

Overview

Child processes let your Node.js application spawn and communicate with other processes β€” whether that means running a shell command like `git log`, launching another Node.js script, executing a Python script, or offloading CPU-intensive work to a separate OS process. The `child_process` built-in module provides four functions for this: `exec`, `execFile`, `spawn`, and `fork`, each suited to different use cases. `exec(command, callback)` runs a command through the shell and buffers the entire stdout and stderr output in memory. It is the most convenient for short commands where you just want the output as a string. `execFile(file, args, callback)` is similar but skips the shell, making it faster and safer for executable files with known argument arrays. Use `execFile` when you control the command; use `exec` only when you need shell features like pipes and redirects. `spawn(command, args)` is the streaming alternative to exec β€” stdout and stderr are exposed as Readable streams so you can process output as it arrives. Use `spawn` for long-running commands (ffmpeg, git clone, npm install) where you do not want to buffer potentially large output in memory. `fork(modulePath)` is a special form of spawn specifically for Node.js child processes. It sets up an IPC (Inter-Process Communication) channel between the parent and child, letting them exchange structured JavaScript objects with `.send()` and the `"message"` event. This is the correct tool for running CPU-intensive Node.js code in a separate process without blocking the event loop.

Key Points

  • β€’exec() buffers full output as a string β€” use for short commands; risky for large output
  • β€’spawn() streams stdout/stderr β€” use for long-running processes or large output
  • β€’execFile() bypasses the shell, making it safer and faster than exec() when you control the arguments
  • β€’fork() creates a Node.js child with an IPC channel for message passing β€” ideal for CPU-intensive JS work
  • β€’Child process stdin/stdout/stderr are separate from the parent unless you use { stdio: "inherit" }
  • β€’Always handle the "error" and "close" events on child processes to detect failures

Examples

exec() β€” run a shell command and get the output as a string

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

const execAsync = promisify(exec);

async function getGitLog() {
  try {
    const { stdout, stderr } = await execAsync('git log --oneline -5');
    console.log('Last 5 commits:\n', stdout);
    if (stderr) console.warn('Stderr:', stderr);
  } catch (err) {
    console.error('Git error:', err.message);
  }
}

getGitLog();

exec() uses the shell, so you can use pipes: exec("cat file.txt | grep error"). But this also means you must sanitize any user input to prevent shell injection.

spawn() β€” stream output from a long-running process

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

function runNpmInstall() {
  const child = spawn('npm', ['install'], {
    cwd: process.cwd(),
    stdio: ['ignore', 'pipe', 'pipe'], // stdin ignored, stdout/stderr piped
  });

  child.stdout.on('data', (chunk) => {
    process.stdout.write(chunk); // stream to parent stdout in real time
  });

  child.stderr.on('data', (chunk) => {
    process.stderr.write(chunk);
  });

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

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

runNpmInstall();

fork() β€” Node.js to Node.js IPC for CPU-heavy work

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

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

child.send({ start: 1, end: 100_000_000 }); // ask child to compute sum

child.on('message', (result) => {
  console.log('Sum received from child:', result.sum);
  child.kill();
});

// worker.js
process.on('message', ({ start, end }) => {
  let sum = 0;
  for (let i = start; i <= end; i++) sum += i;
  process.send({ sum }); // send result back
});

fork() is the only child_process method that sets up a two-way message channel. The child must be a Node.js script.

execFile() β€” safer than exec for calling known executables

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

const execFileAsync = promisify(execFile);

async function listFiles(directory) {
  // execFile does NOT use a shell β€” args are passed as an array
  // This prevents shell injection if directory comes from user input
  const { stdout } = await execFileAsync('ls', ['-la', directory]);
  console.log(stdout);
}

// Safe even if directory contains spaces or special characters
listFiles('/home/alice/my documents');

Never pass unsanitized user input to exec(). Use execFile() with argument arrays instead.

Common Mistakes

  • Using exec() with user-supplied input β€” this is a shell injection vulnerability; use execFile() with argument arrays instead
  • Not handling the "error" event β€” if the executable is not found, an "error" fires (not "close" with a non-zero code)
  • Using exec() for commands that produce large output β€” stdout is buffered; large output causes "maxBuffer exceeded" errors
  • Forgetting that child processes have their own memory space β€” you cannot share JavaScript objects directly, only serialize them via IPC or files

Note

Promisify exec and execFile with util.promisify() for clean async/await usage. For spawn(), wrap it in a Promise manually since it returns a stream-based API that promisify does not handle well.

Exercises

Write Node.js code in your local terminal. Use the editor below to follow along, then mark each exercise as done once it works.

Loading editor…

Completed: 0 / 6 exercises

Knowledge Check

Question 1 of 4

What is the main security risk of using exec() with user-supplied input?