Skip to content

Child Processes

Your Node app runs on one thread and that one thread better not block But sometimes you need to run a Python script , execute a shell command , or offload work to another process without frozen Event Loop Child processes give you OS-level process isolation - separate memory , separate V8 instances , separate everything

The child_process Module

Four ways to spawn children , each for a different scenario

const { spawn, exec, execFile, fork } = require('child_process')

spawn - streaming I/O , best for long-running processes and large data exec - buffered output , best for short commands where you want stdout as one string execFile - like exec but no shell , directly executes a binary (safer) fork - spawns a Node.js process with IPC channel built in

spawn vs exec - Streaming vs Buffered

// spawn - streams stdout as data arrives
const { spawn } = require('child_process')
const child = spawn('find', ['/', '-name', '*.log'])

child.stdout.on('data', (data) => {
  console.log(`stdout chunk: ${data.length} bytes`)
})

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

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

spawn is your default choice. It streams output in chunks as the child produces them so memory stays flat even if the child outputs gigabytes

// exec - buffers everything into one string
const { exec } = require('child_process')

exec('ls -la', (error, stdout, stderr) => {
  if (error) {
    console.error(`exec error: ${error.message}`)
    return
  }
  console.log(`stdout:\n${stdout}`)
  if (stderr) console.error(`stderr:\n${stderr}`)
})

exec buffers up to maxBuffer (default 1MB) then kills the child if output exceeds it Great for "run this quick command and give me the output" - terrible for large data

// execFile - no shell , direct binary execution
const { execFile } = require('child_process')

execFile('/usr/bin/node', ['-e', 'console.log("hello")'], (error, stdout) => {
  console.log(stdout)
})

No shell expansion , no pipe operators , no command chaining This is the safest option when you control the binary path and arguments

fork() - IPC with the Child

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

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

child.send({ cmd: 'process', data: { id: 42, payload: 'analyze this' } })

child.on('message', (msg) => {
  console.log('from child:', msg)
  child.disconnect()
})

child.on('exit', (code) => {
  console.log(`child exited: ${code}`)
})
// worker.js
process.on('message', (msg) => {
  console.log('received:', msg)

  // do CPU work
  const result = msg.data.payload.toUpperCase()

  process.send({ status: 'done', result })
})

fork creates a new Node process with a built-in IPC channel Messages are serialized with JSON.stringify/parse - no function passing , no prototypes

Error Handling - Exit Codes and Stderr

const { spawn } = require('child_process')

function runCommand(cmd, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      timeout: options.timeout || 30000,
      shell: false,
      ...options
    })

    let stdout = ''
    let stderr = ''

    child.stdout.on('data', (data) => { stdout += data })
    child.stderr.on('data', (data) => { stderr += data })

    child.on('error', (err) => {
      reject(new Error(`Failed to spawn: ${err.message}`))
    })

    child.on('exit', (code, signal) => {
      const metadata = { code, signal, stdout, stderr }

      if (code !== 0) {
        if (signal === 'SIGTERM') {
          return reject(new Error(`Process timed out after ${options.timeout}ms`))
        }
        return reject(Object.assign(new Error(`Exit code ${code}`), metadata))
      }

      resolve(metadata)
    })
  })
}

// Usage
try {
  const result = await runCommand('node', ['-e', 'process.exit(1)'], { timeout: 5000 })
  console.log(result.stdout)
} catch (err) {
  console.error(`Command failed: ${err.message}`)
  console.error(`stderr: ${err.stderr}`)
}

Always handle: error event (failed to spawn), exit event (process finished), timeout, and stderr A non-zero exit code does NOT trigger the 'error' event - check code !== 0 in the exit handler

Security - Command Injection via Shell

// DANGER - shell injection vulnerability
const { exec } = require('child_process')
const userInput = req.query.filename  // attacker sends: "; rm -rf / ;"
exec(`cat ${userInput}`, (err, stdout) => {
  console.log(stdout)
})

When shell: true (default for exec), the command string is passed to /bin/sh -c If user input contains ; , | , $() , or backticks - attacker gets arbitrary command execution

// SAFE - pass arguments as array , shell: false
const { spawn } = require('child_process')

spawn('cat', [userInput], { shell: false })
// userInput is passed as a literal argument to cat, not interpreted by shell

Rules: - NEVER use shell: true with user input in the command string - Use spawn with argument arrays instead of exec when possible - If you must use exec , validate and sanitize every input character - Whitelist allowed characters: ^[a-zA-Z0-9_\\-\\.\\/]+$ - execFile is inherently safer than exec (no shell by default)

// Input validation for exec
function sanitizeShellArg(input) {
  // Only allow safe characters
  if (!/^[a-zA-Z0-9_\-\.\/]+$/.test(input)) {
    throw new Error('Invalid characters in input')
  }
  return input
}

const safeFile = sanitizeShellArg(userInput)
exec(`cat ${safeFile}`, (err, stdout) => {
  // safer but still prefer spawn
})

Another attack vector: Argument injection with -- flags If user passes --eval=require('child_process').execSync('rm -rf /') to a Node process , you're fucked

Prerequisites


next -> adv_02_worker_threads.md