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).
💡 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.
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.
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').
💡 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
The 'close' vs 'exit' Events
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.
Choosing the Right Function
| Function | Uses shell? | Output | Best 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.
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.
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.