Child Processes

Node.js Intermediate — Child Processes

Node.js Intermediate

Chapter 3 of 8  ·  Child Processes

Child Processes

Node runs on a single thread, which means CPU-intensive work — image processing, data crunching, running shell tools — blocks the event loop and freezes everything else. Child processes solve this by spawning separate OS processes that run independently. Node provides four functions for this: exec, execFile, spawn, and fork — each suited to a different job.

The Four Functions at a Glance

exec()

Runs a command string in a shell (/bin/sh or cmd.exe). Buffers all output and delivers it in a single callback. Good for short commands where you just want the result.

execFile()

Like exec but bypasses the shell — runs the executable directly with an arguments array. Faster and safer (no shell injection risk). Also buffers output.

spawn()

Streams stdout/stderr in real time. No shell by default. Best for long-running processes or large output where you can't wait for everything to buffer.

fork()

A specialised spawn for Node.js scripts. Opens an IPC channel automatically so parent and child can pass messages back and forth with .send() and 'message' events.

exec() — Quick Shell Commands

exec() is the quickest way to run a shell command and capture its output. The callback receives (error, stdout, stderr).

const { exec } = require('child_process'); // Basic usage exec('ls -la', (err, stdout, stderr) => { if (err) { console.error('Exit code:', err.code); console.error('stderr:', stderr); return; } console.log(stdout); });
// Promisified version (cleaner with async/await) const { promisify } = require('util'); const execAsync = promisify(exec); async function getDiskUsage() { const { stdout } = await execAsync('df -h /'); console.log(stdout); } // Or use the built-in promises API (Node 16+) const { exec: execP } = require('child_process').promises; // not available const cp = require('child_process/promises'); // Node 15+ const { stdout } = await cp.exec('git log --oneline -5'); console.log(stdout);

💡 exec() Has a Default Output Limit

By default exec() buffers up to 1 MB of output. If a command produces more, it errors with ERR_CHILD_PROCESS_STDIO_MAXBUFFER. Raise it with the maxBuffer option or switch to spawn() for large output.

exec('command', { maxBuffer: 10 * 1024 * 1024 }, callback) — 10 MB limit.

execFile() — Safer Than exec()

execFile() skips the shell entirely and runs the binary directly. Pass arguments as an array — no string concatenation, no injection risk.

const { execFile } = require('child_process'); // Arguments as an array — safe, no shell expansion execFile('git', ['log', '--oneline', '-10'], (err, stdout, stderr) => { if (err) { console.error(err); return; } console.log(stdout); }); // Promisified const execFileAsync = promisify(execFile); const { stdout } = await execFileAsync('node', ['--version']); console.log(stdout.trim()); // 'v20.11.0'

spawn() — Streaming Output

spawn() returns a ChildProcess object with stdout and stderr as readable streams. Data arrives in chunks as the process runs — ideal for long-running commands or large output.

const { spawn } = require('child_process'); const child = spawn('ping', ['-c', '4', '8.8.8.8']); // stdout and stderr are Readable streams (from Chapter 1!) child.stdout.on('data', (chunk) => { process.stdout.write(chunk); // stream output as it arrives }); child.stderr.on('data', (chunk) => { process.stderr.write(chunk); }); child.on('close', (code) => { console.log(`Process exited with code ${code}`); }); child.on('error', (err) => { console.error('Failed to start process:', err.message); });
// Piping child stdout directly to a file (streams + child processes combined) const fs = require('fs'); const out = fs.createWriteStream('output.log'); const logger = spawn('find', ['.', '-name', '*.js']); logger.stdout.pipe(out); logger.on('close', () => console.log('Results saved to output.log'));
// spawn with { shell: true } — gets shell features (pipes, globs) but re-introduces injection risk const p = spawn('cat file.txt | grep error', [], { shell: true }); // inherit — child shares parent's stdio directly (output appears in your terminal) const q = spawn('npm', ['install'], { stdio: 'inherit' });

fork() — Node-to-Node with IPC

fork() spawns a new Node.js process from a script file and automatically opens an IPC (Inter-Process Communication) channel. Both sides can send structured JavaScript objects back and forth with .send() and listen with on('message').

// parent.js const { fork } = require('child_process'); const child = fork('./worker.js'); // Send a message to the child child.send({ task: 'compute', input: 40 }); // Listen for messages from the child child.on('message', (result) => { console.log('Result from child:', result); child.disconnect(); // close the IPC channel when done }); child.on('error', (err) => console.error('Child error:', err)); child.on('exit', (code) => console.log(`Child exited: ${code}`));
// worker.js — runs in its own process process.on('message', ({ task, input }) => { if (task === 'compute') { const result = fibonacci(input); // CPU-intensive — safe here, won't block parent process.send({ input, result }); } }); function fibonacci(n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); }

💡 fork() vs Worker Threads

fork() creates a completely separate OS process with its own memory — high isolation, higher overhead (~30 MB per process). Worker Threads (Chapter 4) share memory with the parent and have much lower overhead. Use fork() when you need process isolation or want to run a separate Node script; use Worker Threads for CPU tasks that benefit from shared memory.

Controlling the Child Process

const child = spawn('node', ['long-task.js']); // Write to child's stdin child.stdin.write('some input\n'); child.stdin.end(); // Kill the child if it takes too long const timeout = setTimeout(() => { child.kill('SIGTERM'); // polite request to terminate // child.kill('SIGKILL') — force kill if SIGTERM is ignored }, 5000); child.on('close', (code, signal) => { clearTimeout(timeout); if (signal) { console.log(`Killed by signal: ${signal}`); } else { console.log(`Exited with code: ${code}`); } }); // AbortController — cleaner alternative for cancellation (Node 15+) const ac = new AbortController(); const child2 = spawn('sleep', ['10'], { signal: ac.signal }); setTimeout(() => ac.abort(), 2000); // cancel after 2 s

The 'close' vs 'exit' Events

const child = spawn('ls'); child.on('exit', (code, signal) => { // The process has exited — but stdio streams may still have data buffered console.log('Process exited'); }); child.on('close', (code, signal) => { // All stdio streams have been closed — safe to assume all data was received // Use 'close' in preference to 'exit' when processing stdout/stderr console.log('Process closed — all output received'); });

Shell Injection — The exec() Risk

If you build an exec() command string using unsanitised user input, you hand an attacker shell access. This is the child_process equivalent of SQL injection.

🔴 Never Do This

Passing user input directly into exec():

exec(`grep ${userInput} /var/log/app.log`, cb)

If userInput is foo; rm -rf /, the shell runs both commands. Use execFile() or spawn() with an arguments array instead — they never invoke a shell.

// UNSAFE — user input in exec() string const term = req.query.search; exec(`grep ${term} server.log`, cb); // ← shell injection risk // SAFE — use execFile() with an arguments array execFile('grep', [term, 'server.log'], cb); // ← no shell, no injection // SAFE — spawn() also skips the shell by default spawn('grep', [term, 'server.log']);

Choosing the Right Function

FunctionUses shell?OutputBest for
exec() Yes Buffered (1 MB default) Short shell commands, need shell features (pipes, globs)
execFile() No Buffered (1 MB default) Known binary, safe args, want the result in one go
spawn() No (default) Streaming Long-running processes, large output, real-time progress
fork() No Streaming + IPC CPU-intensive Node.js work, bidirectional messaging

⚠️ Common Mistakes

Listening to 'exit' instead of 'close' when processing output — the process can exit while stdout still has buffered data. Always use 'close' if you care about receiving all output.

Not handling the 'error' event on the child — if the command doesn't exist or can't be started, Node emits 'error' on the child process object. Without a listener, this crashes the parent.

Forgetting that fork() memory isn't shared — data passed via send() is serialised to JSON and deserialised on the other side. Functions, class instances, and circular references can't be sent. Use Worker Threads (next chapter) if you need shared memory.

Coding Challenges

Challenge 1 — Command Runner with Timeout

Write an async function runWithTimeout(command, args, timeoutMs) that spawns a process, collects its stdout into a string, and resolves with { stdout, code } on success. If the process doesn't finish within timeoutMs, kill it and reject with a TimeoutError. Use AbortController for the cancellation.

View sample solution ↗

Challenge 2 — Parallel Task Farm with fork()

Build a TaskFarm class that maintains a pool of N forked worker processes (from a worker.js file). It should expose a run(data) method that sends work to the next available worker and returns a Promise that resolves with the result. Workers should stay alive between tasks and be reused. Include a shutdown() method that gracefully terminates all workers.

View sample solution ↗

Challenge 3 — Live Log Tailer

Write a tailLog(filePath, lines) function that uses spawn() to run tail -f on a file, emitting each new line as a 'line' event on a returned EventEmitter. It should handle partial chunks correctly (a chunk from stdout may contain multiple lines or half a line). Include a stop() method on the returned object that kills the tail process cleanly.

View sample solution ↗