Child Processes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. The examples were run on macOS (arm64): behaviour that depends on the operating system (process groups, signals, pipe sizes, Windows batch files) is labelled as observed or as not run. The node:child_process module is Stability 2 (Stable) as a whole, and the pieces that matter here keep that level: spawn(), exec(), execFile(), fork(), their synchronous twins and the ChildProcess class. What is not stable is limited to a neighbouring topic: --allow-net of the permission model is Stability 1.1 (Active development). A child process is another operating-system process started by yours: it has its own memory, its own event loop and, if it is a Node.js program, its own V8 instance. Node.js can run any program that way, a compiler, git, an image converter, a Python script, or another copy of itself. This page covers the four ways to start one, how to read their results, how to pass arguments and input safely, how to stop and detach children, and how to build a pool of worker processes that talk over an IPC channel. The conversation between your process and signals such as SIGTERM is the subject of Process, Signals & Exit Codes; threads that share memory are in Worker Threads; cloning one server across all cores is in Cluster & Scaling; and the decision between them is summarised in Concurrency Models. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture.

The stability and version map

Table 1. APIs and options covered on this page
API Stability (Node.js 26 docs) Version notes

spawn(), exec(), execFile(), fork()

2 Stable

Added in v0.1.90, v0.1.90, v0.1.91 and v0.5.0. AbortSignal support arrived in v15.4.0 to v15.6.0 (with backports to v14.17.0), timeout and killSignal for spawn() and fork() in v15.13.0 and v15.11.0.

spawnSync(), execSync(), execFileSync()

2 Stable

Added in v0.11.12. The input option accepts any TypedArray or DataView since v10.10.0.

ChildProcess class and its events (spawn, error, exit, close, disconnect, message)

2 Stable

The class was added in v2.2.0. The 'spawn' event was added in v15.1.0 and v14.17.0.

subprocess[Symbol.dispose]()

2 Stable

Calls kill() with 'SIGTERM'. Added in v20.5.0 and v18.18.0, no longer experimental in v24.2.0. Using it with the using keyword needs a JavaScript engine that supports explicit resource management: Node.js 24 and 26, not 22 (observed).

subprocess.send() and the IPC channel

2 Stable

Returns a boolean for flow control since v5.0.0. Sending a net.BoundSocket as the handle is new in v26.10.0.

serialization: 'advanced'

2 Stable

Added in v13.2.0 and v12.16.0. The default is 'json'.

shell option together with an args array (spawn(), execFile())

Deprecated (DEP0190)

Documentation-only deprecation in v23.11.0 and v22.15.0, runtime deprecation in v24.0.0: a warning is printed on Node.js 24 and 26, not on 22 (observed).

Using an AbortSignal to dispose of a long-lived child (DEP0209)

Deprecated, documentation-only

Since v26.8.2. signal stays appropriate for cancellation, propagated aborts and timeouts; prefer using for cleanup.

util.convertProcessSignalToExitCode()

Stable

Added in v25.4.0 and v24.14.0, so absent on Node.js 22 (observed).

--permission with --allow-child-process

2 Stable

Added in v20.0.0. The --allow-net flag next to it is 1.1 Active development, added in v25.0.0.

Choosing between spawn, exec, execFile and fork

All four asynchronous functions return a ChildProcess, which is an EventEmitter. They differ in who interprets the command, what happens to the output and whether a message channel exists.

Table 2. The four ways to start a child process
Function Starts Output Use it for

spawn(command, args, options)

The program directly, with an argument array; no shell unless shell is set

Streams: child.stdout and child.stderr are Readable, child.stdin is Writable

Anything long-running or with a lot of output; the building block of the other three

execFile(file, args, options, callback)

The program directly, with an argument array; no shell by default

Buffered, passed to the callback (or the promisified result), limited by maxBuffer (default 1 MiB)

Short programs with small output when you want a promise or callback; the safe default for "run this tool"

exec(command, options, callback)

A shell (/bin/sh, or cmd.exe on Windows) that parses one command string

Buffered, limited by maxBuffer (default 1 MiB)

Shell features (pipes, redirection, globs) with a string you fully control; never with user input

fork(modulePath, args, options)

A new Node.js process running a script, with an IPC channel

Inherited from the parent unless silent or stdio say otherwise

Offloading CPU work to another Node.js process; see fork() and IPC done right

The synchronous twins (spawnSync(), execFileSync(), execSync()) block the event loop until the child ends; they are for scripts and start-up code, never for a request handler (see The synchronous variants). Underneath, every start goes through libuv’s process API, which fork(2)`s and `exec(2)`s on POSIX and calls `CreateProcess on Windows; Node.js never replaces the current process (the exec in its name is not the POSIX call), and fork() does not clone the current process as the POSIX call would. A start can fail before the program runs (ENOENT when the file is not found, EACCES when it is not executable): that is reported as an 'error' event, not as an exit code.

spawn() and the ChildProcess object

spawn() takes the program, an array of arguments and an options object. Because the arguments are separate array entries, no quoting rules apply: a space, a semicolon or a quote inside an entry is just a character. The program below prints the arguments it receives, and the parent sees the events in a fixed order.

Spawning without a shell and listening to the life cycle
import { spawn } from 'node:child_process';
import { once } from 'node:events';

// The command and its arguments are separate values: no shell, no quoting.
const child = spawn(process.execPath, ['-p', '"hello " + process.argv.slice(1).join(",")', 'a b', 'c;d']);

child.on('spawn', () => console.log('spawn  pid is', typeof child.pid));
child.stdout.setEncoding('utf8').on('data', (chunk) => console.log('stdout', JSON.stringify(chunk)));
child.stderr.setEncoding('utf8').on('data', (chunk) => console.log('stderr', JSON.stringify(chunk)));
child.on('exit', (code, signal) => console.log('exit  ', { code, signal }));

const [code, signal] = await once(child, 'close');
console.log('close ', { code, signal });
Output
spawn  pid is number
stdout "hello a b,c;d\n"
exit   { code: 0, signal: null }
close  { code: 0, signal: null }

The returned object exposes the things you normally need:

Table 3. Useful ChildProcess properties and methods
Member Meaning

pid

The process id, or undefined if the child could not be spawned.

stdin, stdout, stderr, stdio

The parent’s ends of the pipes. They are null for any descriptor not configured as 'pipe' (see Configuring stdio).

exitCode, signalCode

null while the child is running; afterwards either the exit code or the name of the terminating signal.

killed

true once kill() has successfully sent a signal. It does not mean the child has exited.

kill([signal])

Sends 'SIGTERM' by default and returns whether the signal was delivered. See Timeouts, AbortSignal and stopping children.

spawnfile, spawnargs

What was actually executed; useful in logs.

connected, send(), disconnect(), channel

The IPC channel, present when the child was started with fork() or with an 'ipc' stdio entry.

ref(), unref()

Whether the child keeps the parent’s event loop (and therefore the parent process) alive. See detached, unref and background processes.

Because stdout and stderr are ordinary streams, everything in Streams applies: they have a highWaterMark, they pause when nobody reads, and an unread pipe can make the child block once the operating system’s pipe buffer fills. The practical rule is to consume or ignore every pipe you ask for (see Configuring stdio).

Events: spawn, error, exit and close

Event When it fires

'spawn'

Once the child has started successfully, before any other event and before any data. It fires even if the program then fails at its own work: with { shell: true }, a shell that cannot find the command still counts as a successful spawn.

'error'

The process could not be spawned, could not be killed, a message could not be sent, or the signal option aborted it. 'exit' may or may not follow, so handlers that listen to both must not run twice.

'exit'

The child has ended. You get (code, signal) and exactly one is non-null. The child’s stdio streams may still be open.

'close'

The child has ended and its stdio streams are closed. It always comes after 'exit' (or after 'error' for a failed spawn). This is the event to wait for when you collect output.

'disconnect'

The IPC channel was closed by either side.

'message'

The child called process.send().

The difference between 'exit' and 'close' is not academic. Several processes can share one stdio pipe, so a child can exit while a grandchild it started still holds the same stdout open. The next program makes that visible: the child starts a grandchild that inherits stdout and exits at once; 'exit' arrives after a few milliseconds, but 'data' and 'close' wait for the grandchild.

exit arrives first, close waits for the stdio streams
import { spawn } from 'node:child_process';

// The child starts a grandchild that inherits stdout and outlives it by 500 ms.
const script = `
  const { spawn } = require('node:child_process');
  const grandchild = spawn(process.execPath, ['-e', 'setTimeout(() => console.log("late output"), 500)'],
        { stdio: ['ignore', 'inherit', 'inherit'] });
  grandchild.unref(); // let the child exit now; the grandchild keeps the shared stdout open
`;
const t0 = Date.now();
const at = (label) => console.log(String(Date.now() - t0).padStart(4, ' '), 'ms', label);

const child = spawn(process.execPath, ['-e', script]);
child.stdout.on('data', (d) => at(`data: ${d.toString().trim()}`));
child.on('exit', () => at('exit'));
child.on('close', () => at('close'));
Output (observed)
  24 ms exit
 544 ms data: late output
 546 ms close

If you read the output, wait for 'close'; if you only care that the process is gone, 'exit' is enough. When a signal ended the child, code is null, and util.convertProcessSignalToExitCode() (Node.js 24.14+, 25.4+) turns the signal into the exit code a shell would show, for example 143 for SIGTERM (see Timeouts, AbortSignal and stopping children).

A promise-based helper

Wrapping the events once gives the rest of the program an async function. The helper below runs a program without a shell, collects bounded output, kills the child if it prints too much, and rejects for spawn failures and aborts. It settles on 'close', so the output is complete, and wires 'error' directly. (events.once(child, 'close') is a shorter way to wait, but it rejects on 'error', so the rejection must be handled.)

A bounded run() helper built on spawn()
import { spawn } from 'node:child_process';

/**
 * Run a command without a shell and collect bounded output.
 * Rejects when the process cannot start (ENOENT, EACCES), is aborted, or prints too much.
 */
export function run(command, args = [], { maxBytes = 1024 * 1024, signal, ...options } = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, { ...options, signal });
    const out = [];
    const err = [];
    let size = 0;

    const collect = (list) => (chunk) => {
      size += chunk.length;
      if (size > maxBytes) {
        child.kill();
        reject(new RangeError(`output of ${command} exceeded ${maxBytes} bytes`));
        return;
      }
      list.push(chunk);
    };
    child.stdout.on('data', collect(out));
    child.stderr.on('data', collect(err));

    child.once('error', reject); // spawn failure, kill failure, AbortError
    child.once('close', (code, sig) => {
      resolve({
        code,
        signal: sig,
        stdout: Buffer.concat(out).toString('utf8'),
        stderr: Buffer.concat(err).toString('utf8'),
      });
    });
  });
}

if (import.meta.main) {
  console.log(await run(process.execPath, ['-p', '6 * 7']));
  console.log(await run(process.execPath, ['-e', 'process.exit(3)']));
  await run('definitely-not-a-command').catch((err) => console.log(err.code, err.syscall, err.path));
  await run(process.execPath, ['-e', 'for (;;) process.stdout.write("x".repeat(1000))'], { maxBytes: 100_000 })
    .catch((err) => console.log(err.name, err.message));
}
Output
{ code: 0, signal: null, stdout: '42\n', stderr: '' }
{ code: 3, signal: null, stdout: '', stderr: '' }
ENOENT spawn definitely-not-a-command definitely-not-a-command
RangeError output of /Users/<you>/.nvm/versions/node/v26.10.0/bin/node exceeded 100000 bytes

A non-zero exit code is not an error to spawn(): it is data in 'close' and 'exit'. Decide in your code which codes mean failure.

exec() and execFile()

exec() and execFile() buffer the child’s output and hand it to a callback as (error, stdout, stderr). Both are also available through util.promisify(), which resolves to { stdout, stderr } and attaches the ChildProcess to the promise as a child property. A non-zero exit code, a signal, a timeout or an exceeded maxBuffer all become a rejection; the error carries code (the exit code, or a string such as ERR_CHILD_PROCESS_STDIO_MAXBUFFER), signal, killed, cmd, and the stdout and stderr collected so far.

exec versus execFile, errors and limits
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

// exec: one string, interpreted by /bin/sh (cmd.exe on Windows): pipes and globs work.
const piped = await execAsync(`${JSON.stringify(process.execPath)} -p "1 + 1" | cat`);
console.log('exec    ->', JSON.stringify(piped.stdout));

// execFile: file + argument array, no shell.
const { stdout } = await execFileAsync(process.execPath, ['--version']);
console.log('execFile->', stdout.trim());

// A non-zero exit rejects; the error carries code, signal, stdout and stderr.
try {
  await execFileAsync(process.execPath, ['-e', 'console.log("partial"); console.error("boom"); process.exit(2)']);
} catch (err) {
  console.log({ code: err.code, signal: err.signal, killed: err.killed, stdout: err.stdout, stderr: err.stderr });
}

// timeout and maxBuffer both terminate the child.
await execFileAsync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { timeout: 300 })
  .catch((err) => console.log('timeout   ->', { killed: err.killed, signal: err.signal, code: err.code }));
await execFileAsync(process.execPath, ['-p', '"x".repeat(5000)'], { maxBuffer: 1000 })
  .catch((err) => console.log('maxBuffer ->', err.code, err.stdout.length));
Output
exec    -> "2\n"
execFile-> v26.10.0
{
  code: 2,
  signal: null,
  killed: false,
  stdout: 'partial\n',
  stderr: 'boom\n'
}
timeout   -> { killed: true, signal: 'SIGTERM', code: null }
maxBuffer -> ERR_CHILD_PROCESS_STDIO_MAXBUFFER 1000

The options that matter, with their documented defaults:

Table 4. Options shared by exec() and execFile()
Option Default Meaning

cwd

process.cwd()

Working directory; a string or a file: URL.

env

process.env

The whole environment of the child; see Working directory, environment and identity.

encoding

'utf8'

Use 'buffer' to receive Buffer objects instead of strings.

timeout

0 (none)

Milliseconds after which killSignal is sent.

killSignal

'SIGTERM'

Signal for a timeout or an abort.

maxBuffer

1024 * 1024

Bytes allowed on stdout or on stderr. When exceeded, the child is terminated and the output is truncated. The limit counts bytes, not characters: a multi-byte character costs more than one.

signal

none

An AbortSignal; aborting is like kill(), and the error is an AbortError.

shell

'/bin/sh' or process.env.ComSpec for exec(); false for execFile()

Which shell interprets the command. With execFile() it turns the shell on, with all the dangers in Shell injection and DEP0190.

uid, gid

none

Run the child as another user or group (POSIX).

windowsHide

false

Hide the console window a child would get on Windows.

execFile() is slightly cheaper than exec() because no shell is started, and it cannot do redirection, globbing or pipes: those are shell features. When you want a pipeline of programs without a shell, connect the children yourself (see Connecting children to each other).

Shell injection and DEP0190

A shell turns a string into a command by interpreting special characters: ;, &&, |, backticks, $(…​), quotes, redirections and globs. When any part of that string comes from a user, a file name, an HTTP parameter or a database row, the user can add commands of their own. The official documentation says it twice: exec() must never be given unsanitised input, and the same warning applies to spawn(), execFile(), spawnSync(), execFileSync() and execSync() whenever the shell option is on. The example uses a harmless second command to show the mechanism.

Interpolating input into exec() versus passing it as an argument
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

// A harmless stand-in for hostile input: a second command after the semicolon.
const userInput = 'hello; echo INJECTED-COMMAND-RAN';

// Vulnerable: the whole string is parsed by the shell.
const bad = await execAsync(`echo ${userInput}`);
console.log('exec     :', JSON.stringify(bad.stdout));

// Safe: the input is exactly one argv entry; no shell ever sees it.
const good = await execFileAsync('echo', [userInput]);
console.log('execFile :', JSON.stringify(good.stdout));
Output
exec     : "hello\nINJECTED-COMMAND-RAN\n"
execFile : "hello; echo INJECTED-COMMAND-RAN\n"

The first call ran a second command; in the second the whole string is one argv entry, printed back as data. The rules that follow:

  • Pass the program and its arguments as an array to spawn(), execFile() or their sync twins, with no shell option. Do not build the array by splitting a string.

  • Never interpolate input into exec() or execSync(). If you need shell features, put the fixed script in the command and hand the variable parts over as arguments or environment variables, which the shell does not re-parse.

  • Validate input against an allowlist (a pattern, an enum, a lookup table) rather than trying to escape it.

  • Remember that the program parses its own arguments. Even without a shell, an input such as -l or --output=…​ is treated as an option by most tools. End option parsing with -- where the tool supports it, and reject values that start with a dash.

Argument injection without a shell, and an allowlist
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const execFileAsync = promisify(execFile);
const dir = await mkdtemp(join(tmpdir(), 'cp-args-'));
await writeFile(join(dir, 'shelf.txt'), 'x');

// No shell, yet the program still parses its own options: user input "-l" is an option, not a name.
const userInput = '-l';
console.log('option  :', JSON.stringify((await execFileAsync('ls', [userInput], { cwd: dir })).stdout));

// "--" ends option parsing for most POSIX tools; prefer an allowlist for what the input may be.
const ALLOWED = /^[\w.-]+$/;
function listOne(name) {
  if (!ALLOWED.test(name) || name.startsWith('-')) throw new TypeError(`rejected file name: ${name}`);
  return execFileAsync('ls', ['--', name], { cwd: dir });
}
console.log('allowed :', JSON.stringify((await listOne('shelf.txt')).stdout));
await Promise.resolve().then(() => listOne('-l')).catch((err) => console.log('rejected:', err.message));
Output (observed on macOS)
option  : "total 8\n-rw-r--r--@ 1 you  staff  1 Oct  4 00:48 shelf.txt\n"
allowed : "shelf.txt\n"
rejected: rejected file name: -l

DEP0190: arguments with a shell

A tempting compromise is spawn('cmd', [userInput], { shell: true }). It looks safe and is not: with shell set, the array is only joined with spaces and handed to the shell, with no escaping at all. Passing args together with shell is deprecated as DEP0190: a documentation-only deprecation in v23.11.0 and v22.15.0 and a runtime deprecation (a printed warning) since v24.0.0. The program below shows the array being joined and interpreted.

args plus shell: true is concatenation, not quoting
import { spawn } from 'node:child_process';
import { once } from 'node:events';

// DEP0190: args plus shell: the array is only joined with spaces, never escaped.
const child = spawn('echo', ['first', '&& echo', 'second'], { shell: true, stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.setEncoding('utf8').on('data', (d) => console.log(JSON.stringify(d)));
await once(child, 'close');
Output (Node.js 26 and 24; Node.js 22 prints no warning, observed)
(node:50751) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
"first\nsecond\n"

The array entry && echo became a second command. The fix is the same as before: drop shell, or, if a shell is truly required, build one fixed command string that contains no variable parts.

Windows: .bat and .cmd files

On Windows, batch files are not executables: they need cmd.exe to run them. Until April 2024 spawn() papered over that, which allowed a malicious argument to inject commands even without the shell option (CVE-2024-27980, rated high). Since the fix, passing a .bat or .cmd file to spawn() or spawnSync() without a shell throws an EINVAL error, and execFile() cannot launch them either. The documentation lists three ways to run one, and the safe one is to start cmd.exe yourself with fixed arguments; if the file name contains a space it has to be quoted, and any user-supplied argument has to be validated first because cmd.exe is a shell. The code below is quoted from the documented pattern for reference; it was not run here because the examples of this page ran on macOS (observed), and the EINVAL behaviour could not be reproduced without Windows.

Starting a batch file on Windows (not run)
import { exec, spawn } from 'node:child_process';

// Runs through cmd.exe: only for fixed, trusted file names and arguments.
exec('my.bat', (err, stdout, stderr) => { /* ... */ });

// Or start cmd.exe directly and pass the file as an argument.
const bat = spawn('cmd.exe', ['/c', 'my.bat']);

// A script name with spaces must be quoted.
exec('"my script.cmd" a b', (err, stdout, stderr) => { /* ... */ });

Other Windows differences worth knowing: the default shell is process.env.ComSpec (falling back to cmd.exe), a custom shell must understand -c (or /d /s /c for cmd.exe), windowsVerbatimArguments turns off argument quoting, and POSIX signals do not exist (see Timeouts, AbortSignal and stopping children).

Configuring stdio

The stdio option of spawn() decides what each file descriptor of the child is connected to. Index 0 is stdin, 1 is stdout, 2 is stderr; higher indexes are extra descriptors. A string form sets all three at once.

Table 5. stdio values
Value Meaning

'pipe' (default)

A pipe whose parent end is child.stdin, child.stdout or child.stderr (or child.stdio[n]). These are not real Unix pipes from the child’s point of view: it cannot reopen them through /dev/fd/2 or /dev/stdout.

'overlapped'

Like 'pipe' with the overlapped-I/O flag set on Windows; identical to 'pipe' elsewhere.

'ignore'

The child gets /dev/null; the matching child.stdout is null.

'inherit'

The child uses the parent’s descriptor: its output appears in the parent’s terminal. In a position above 2 it behaves like 'ignore'.

'ipc'

Creates the message channel used by send(). At most one per child. See fork() and IPC done right.

a stream object or an integer

A stream with an underlying file descriptor (a file stream must already be open), or a descriptor number open in the parent, is shared with the child. Pass a readable source for stdin and writable destinations for stdout and stderr.

null, undefined

The default: a pipe for 0, 1 and 2, 'ignore' for 3 and up.

The stdio options in practice
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createReadStream, createWriteStream, openSync, closeSync, readFileSync, writeFileSync } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const dir = await mkdtemp(join(tmpdir(), 'cp-stdio-'));
const upper = ['-e', 'process.stdin.setEncoding("utf8").on("data", (s) => process.stdout.write(s.toUpperCase()))'];

// 1. 'inherit': the child writes straight to this process's terminal.
await once(spawn(process.execPath, ['-p', '"inherit: written by the child"'], { stdio: 'inherit' }), 'close');

// 2. 'ignore': the child's streams are /dev/null; nothing to read, nothing to leak.
const quiet = spawn(process.execPath, ['-p', '"never seen"'], { stdio: 'ignore' });
console.log('ignore  : stdout is', quiet.stdout);
await once(quiet, 'close');

// 3. Pipes in both directions: stream a file through the child with backpressure and cleanup.
const input = join(dir, 'in.txt');
const output = join(dir, 'out.txt');
writeFileSync(input, 'bookshelf\n');
const filter = spawn(process.execPath, upper, { stdio: ['pipe', 'pipe', 'inherit'] });
await Promise.all([
  pipeline(createReadStream(input), filter.stdin),
  pipeline(filter.stdout, createWriteStream(output)),
  once(filter, 'close'),
]);
console.log('pipes   :', JSON.stringify(readFileSync(output, 'utf8')));

// 4. A file descriptor: the child's stdout and stderr go to one log file, no pipe in the parent.
const log = openSync(join(dir, 'child.log'), 'a');
const logged = spawn(process.execPath, ['-e', 'console.log("to log"); console.error("also to log")'], { stdio: ['ignore', log, log] });
await once(logged, 'close');
closeSync(log);
console.log('fd      :', JSON.stringify(readFileSync(join(dir, 'child.log'), 'utf8')));

// 5. Sharing only stderr with the parent.
await once(spawn(process.execPath, ['-e', 'console.error("shared stderr")'], { stdio: ['ignore', 'pipe', process.stderr] }), 'close');
Output
inherit: written by the child
ignore  : stdout is null
pipes   : "BOOKSHELF\n"
fd      : "to log\nalso to log\n"
shared stderr

Three practical rules fall out of the table. First, use 'inherit' for command-line tools that should talk to the user’s terminal, such as an installer step in a script. Second, use 'ignore' or a file descriptor for output you do not need, because an unread pipe fills its operating-system buffer and then blocks the child. Third, if you do read a pipe, treat it as a stream: use pipeline() so errors and early exits close both sides.

Working directory, environment and identity

cwd sets the working directory of the child (a string or a file: URL); if the directory does not exist, the failure is reported as an ENOENT error that names the executable, which can mislead (observed). env replaces the whole environment, and undefined values in it are ignored. The default is process.env, so a child inherits every variable of the parent: tokens, database passwords and cloud credentials included. Build the child’s environment explicitly instead of spreading process.env.

Inheriting versus building the environment
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { realpathSync } from 'node:fs';

const execFileAsync = promisify(execFile);
process.env.BOOKSHELF_TOKEN = 'parent-only-placeholder';

const probe = ['-p', 'JSON.stringify({ cwd: process.cwd(), mode: process.env.MODE, token: process.env.BOOKSHELF_TOKEN ?? null, hasPath: Boolean(process.env.PATH) })'];

// Inherit everything (the default): the child sees the parent's secrets.
console.log((await execFileAsync(process.execPath, probe)).stdout.trim());

// Build the environment explicitly: the child gets only what you pass.
const tmp = realpathSync(tmpdir());
const { stdout } = await execFileAsync(process.execPath, probe, {
  cwd: tmp,
  env: { PATH: process.env.PATH, MODE: 'export' },
});
console.log(stdout.trim());

// cwd may also be a file: URL; undefined values in env are ignored.
const viaUrl = await execFileAsync(process.execPath, probe, { cwd: pathToFileURL(`${tmp}/`), env: { MODE: 'url', PATH: process.env.PATH, UNUSED: undefined } });
console.log(viaUrl.stdout.trim());

// A missing cwd surfaces as ENOENT.
await execFileAsync(process.execPath, probe, { cwd: '/no/such/dir' }).catch((err) => console.log(err.code, err.syscall));
Output (shortened)
{"cwd":"/path/to/ex","token":"parent-only-placeholder","hasPath":true}
{"cwd":"/private/var/folders/.../T","mode":"export","token":null,"hasPath":true}
{"cwd":"/private/var/folders/.../T","mode":"url","token":null,"hasPath":true}
ENOENT spawn /path/to/node

The PATH entry is the one most programs need; add only what the child actually requires. On POSIX uid and gid run the child as another user and argv0 sets the name the child sees as argv[0] (a Node.js child still reports process.execPath in process.argv[0] and exposes the original through process.argv0). Secrets should reach the child through an inherited descriptor, a pipe on stdin or an environment you built, never through arguments, which other users can read in the process list.

Timeouts, AbortSignal and stopping children

There are three ways to bound a child’s life: the timeout option, an AbortSignal in signal, and calling kill() yourself. The first two exist for spawn(), exec(), execFile() and fork(). With signal, aborting behaves like kill() and the child emits 'error' with an AbortError (the abort reason is available as its cause); exec() and execFile() also pass that error to their callback.

Abort, timeout and a graceful kill with escalation
import { spawn, execFile } from 'node:child_process';
import { once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';

const sleeper = ['-e', 'setInterval(() => {}, 1000)'];

// 1. AbortSignal.timeout(): the child is killed and 'error' fires with an AbortError.
{
  const child = spawn(process.execPath, sleeper, { signal: AbortSignal.timeout(300) });
  const [err] = await once(child, 'error');
  console.log('abort   :', err.name, err.code, '| child.killed =', child.killed);
  await once(child, 'close');
}

// 2. The timeout option (no signal needed): SIGTERM by default, killSignal to change it.
{
  const child = spawn(process.execPath, sleeper, { timeout: 300, killSignal: 'SIGKILL' });
  const [code, signal] = await once(child, 'exit');
  console.log('timeout :', { code, signal });
}

// 3. A manual controller, for cancellation driven by something else (a user, a request).
{
  const controller = new AbortController();
  const finished = new Promise((resolve) => {
    execFile(process.execPath, sleeper, { signal: controller.signal }, (err) => {
      console.log('callback:', err.name, '| cause:', err.cause.message);
      resolve();
    });
  });
  setTimeout(() => controller.abort(new Error('user cancelled')), 200);
  await finished;
}

// 4. SIGTERM can be handled: kill() only asks. Escalate to SIGKILL after a grace period.
{
  const polite = ['-e', `process.on('SIGTERM', () => { console.log('child: cleaning up'); setTimeout(() => process.exit(0), 100); }); setInterval(() => {}, 1000)`];
  const child = spawn(process.execPath, polite, { stdio: ['ignore', 'inherit', 'inherit'] });
  await once(child, 'spawn');
  await sleep(200); // let the handler install
  child.kill('SIGTERM');
  const grace = setTimeout(() => child.kill('SIGKILL'), 2000);
  const [code, signal] = await once(child, 'exit');
  clearTimeout(grace);
  console.log('graceful:', { code, signal, killed: child.killed });
}
Output
abort   : AbortError ABORT_ERR | child.killed = true
timeout : { code: null, signal: 'SIGKILL' }
callback: AbortError | cause: user cancelled
child: cleaning up
graceful: { code: 0, signal: null, killed: true }

Points worth remembering:

  • kill() asks: SIGTERM can be caught, so a robust parent waits for 'exit' and escalates to SIGKILL after a grace period, as the last case does. child.killed only says that a signal was sent.

  • Killing a child that has already exited is not an error but can hit an unrelated process if the operating system reused the id.

  • exec() and execFile() rejections for a timeout have killed: true and the signal name; for spawn() use the 'exit' event.

  • DEP0209 (documentation-only, v26.8.2) deprecates using an AbortSignal as a way to dispose of a long-lived resource. For children, keep signal for cancellation, propagated aborts and timeouts, and use using (below) for cleanup. The same guidance appears in Async Context & Cancellation.

  • On Windows there are no POSIX signals: 'SIGKILL', 'SIGTERM', 'SIGINT', 'SIGQUIT' and other names Windows knows terminate the child abruptly, 'SIGWINCH' throws an ENOSYS error and a name that does not exist on Windows throws ERR_UNKNOWN_SIGNAL.

Children of children

kill() signals only the process you started. If that process is a shell that started something else, the grandchild keeps running. This is the usual way for orphans to appear when shell: true or a shell command is used. On POSIX, start the child with detached: true so it leads its own process group, then signal the group with a negative process id.

Killing a process group (POSIX, observed on macOS)
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';

// sh -c starts a grandchild. kill() reaches only the shell; the grandchild survives.
const grandchildScript = `setInterval(() => {}, 1000); console.log(process.pid)`;
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };

async function launch(options) {
  const child = spawn('sh', ['-c', `${JSON.stringify(process.execPath)} -e '${grandchildScript}' & wait`], { stdio: ['ignore', 'pipe', 'inherit'], ...options });
  const [chunk] = await once(child.stdout, 'data');
  return { child, grandchildPid: Number(chunk.toString()) };
}

// A. Plain kill(): the shell dies, the grandchild lives on.
{
  const { child, grandchildPid } = await launch({});
  child.kill();
  await once(child, 'exit');
  await sleep(200);
  console.log('kill()          grandchild alive:', alive(grandchildPid));
  process.kill(grandchildPid); // clean up the orphan
}

// B. detached: true makes the child a process-group leader; signal the whole group with a negative pid (POSIX only).
{
  const { child, grandchildPid } = await launch({ detached: true });
  process.kill(-child.pid, 'SIGTERM');
  await once(child, 'exit');
  await sleep(200);
  console.log('kill(-pid) group grandchild alive:', alive(grandchildPid));
}
Output
kill()          grandchild alive: true
kill(-pid) group grandchild alive: false

Disposal and exit codes

A ChildProcess implements Symbol.dispose (it calls kill() with 'SIGTERM'), so a using declaration stops the child when the block ends, including when an exception leaves it. The syntax needs explicit resource management in the engine: it ran on Node.js 26 and 24 and is a syntax error on 22 (observed), where a try/finally block does the same job. A signal-killed child has code === null; util.convertProcessSignalToExitCode() converts the signal into the 128-plus-signal convention.

using for cleanup, and signals as exit codes
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { convertProcessSignalToExitCode } from 'node:util';

// 'using' kills the child (SIGTERM) when the block exits, including on an exception.
let pid;
{
  using child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']);
  pid = child.pid;
  await once(child, 'spawn');
}
await new Promise((resolve) => setTimeout(resolve, 200));
try { process.kill(pid, 0); console.log('still alive'); } catch { console.log('child was disposed'); }

// code is null when a signal ended the child: translate it the way a shell would (128 + signal number).
const killed = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)']);
await once(killed, 'spawn');
killed.kill('SIGTERM');
const [code, signal] = await once(killed, 'exit');
console.log({ code, signal, shellStyle: code ?? convertProcessSignalToExitCode(signal) });
Output
child was disposed
{ code: null, signal: 'SIGTERM', shellStyle: 143 }

Exit codes, SIGINT forwarding and what to do when the parent is asked to stop are covered in Process, Signals & Exit Codes. A typical parent forwards SIGINT and SIGTERM to its children and exits only after they have exited.

detached, unref and background processes

By default the parent waits for its children: a running child keeps the parent’s event loop alive. detached: true changes the child’s relationship to the parent; unref() changes the parent’s relationship to the child. They are different tools and a background process needs both.

Table 6. detached and unref
Setting Effect

detached: true on POSIX

The child becomes the leader of a new process group and session (setsid). It no longer receives terminal signals sent to the parent’s group, and you can signal its whole group.

detached: true on Windows

The child can keep running after the parent exits and gets its own console window.

child.unref()

The child is no longer counted as a reason to keep the parent’s event loop running. The parent can exit while the child runs, unless an IPC channel connects them. child.ref() undoes it.

stdio: 'ignore' (or a file descriptor)

Required for a background process: a child whose stdio is inherited from the parent stays attached to the controlling terminal, and a pipe nobody reads would eventually block it.

On POSIX a child may outlive its parent even without detached; the combination of detached, unattached stdio and unref() states the intent explicitly and behaves the same on every platform.

A launcher that exits while its worker keeps running
import { spawn } from 'node:child_process';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';

if (process.argv[2] === 'check') {
  // Runs as a separate program, after the launcher below has already exited.
  await sleep(1200);
  console.log('checker:', await readFile(process.argv[3], 'utf8').catch((e) => e.code));
} else {
  const dir = await mkdtemp(join(tmpdir(), 'cp-detached-'));
  const marker = join(dir, 'done.txt');

  // The worker outlives its parent: new session, no stdio tied to the parent, no reference in the parent's loop.
  const worker = spawn(
    process.execPath,
    ['-e', `setTimeout(() => require('node:fs').writeFileSync(process.argv[1], 'finished after the launcher exited'), 700)`, marker],
    { detached: true, stdio: 'ignore' },
  );
  worker.unref();
  console.log('launcher: started worker', typeof worker.pid, '- exiting now');

  // Only so this demonstration can show the result: another, also detached, program checks the marker later.
  const checker = spawn(process.execPath, [import.meta.filename, 'check', marker], { detached: true, stdio: 'inherit' });
  checker.unref();
}
Output
launcher: started worker number - exiting now
checker: finished after the launcher exited

A process you detach is a process you must be able to find and stop later: write its pid to a file or hand it to a supervisor. Long-lived services are better run by a real process manager or by the container platform; see Production Deployment.

The synchronous variants

spawnSync(), execFileSync() and execSync() run the child to completion and return, blocking the event loop in the meantime: no timers, no I/O callbacks and no other request are served while they wait. They are the right tool for scripts, build steps and reading a configuration command at start-up, and the wrong tool inside a server (see Don’t Block the Event Loop).

Table 7. Behaviour of the sync variants
Function What it does on failure

spawnSync()

Never throws for a non-zero exit. Returns an object with status, signal, stdout, stderr, pid, output and error; a spawn failure, a timeout or an exceeded maxBuffer is in result.error.

execFileSync(), execSync()

Throw when the child exits non-zero or times out; the error carries the full result of the underlying spawnSync() (status, stdout, stderr).

Their defaults differ from the async functions: encoding is 'buffer', timeout is undefined, and by default the child’s stderr is printed to the parent’s stderr unless you set stdio. The input option feeds stdin from a string, Buffer, TypedArray or DataView. After a timeout the call does not return until the process has really exited, even if it ignores SIGTERM.

spawnSync, execFileSync and execSync
import { spawnSync, execFileSync, execSync } from 'node:child_process';

// spawnSync never throws for a non-zero exit; it returns everything it learned.
const r = spawnSync(process.execPath, ['-e', 'console.log("out"); console.error("err"); process.exit(4)'], { encoding: 'utf8' });
console.log({ status: r.status, signal: r.signal, stdout: r.stdout, stderr: r.stderr, error: r.error });

// A spawn failure shows up as result.error, not as an exception.
console.log('missing:', spawnSync('definitely-not-a-command').error.code);

// stdin from a string or Buffer through `input`.
console.log('input  :', execFileSync(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'piped in', encoding: 'utf8' }));

// execFileSync / execSync throw on a non-zero exit and on a timeout.
try {
  execFileSync(process.execPath, ['-e', 'process.exit(9)'], { stdio: 'pipe' });
} catch (err) {
  console.log('thrown :', { status: err.status, name: err.name });
}
try {
  execFileSync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { timeout: 300 });
} catch (err) {
  console.log('timeout:', err.code, err.signal);
}

// maxBuffer applies here too.
const big = spawnSync(process.execPath, ['-p', '"x".repeat(5000)'], { maxBuffer: 1000, encoding: 'utf8' });
console.log('maxBuffer:', big.error?.code, big.stdout.length);

// Startup-time configuration is the use case the documentation names; do not use these inside a request handler.
console.log('shell  :', execSync('echo $((6 * 7))', { encoding: 'utf8' }).trim());
Output
{
  status: 4,
  signal: null,
  stdout: 'out\n',
  stderr: 'err\n',
  error: undefined
}
missing: ENOENT
input  : piped in
thrown : { status: 9, name: 'Error' }
timeout: ETIMEDOUT SIGTERM
maxBuffer: ENOBUFS 5001
shell  : 42

With maxBuffer the sync functions report ENOBUFS in error. In this run the whole 5001 characters were still returned (observed), unlike the async exec() above, which kept 1000: treat the limit as a guard that ends the child and flags the result, not as an exact cut-off, and do not rely on partial output.

Connecting children to each other

A shell pipeline such as a | b is two processes whose stdout and stdin are connected. Without a shell you connect the pipes yourself, and stream.pipeline() does it with proper backpressure and cleanup: if one side fails or exits early, the other is destroyed.

producer | consumer without a shell
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { pipeline } from 'node:stream/promises';
import { Writable } from 'node:stream';

// producer | consumer, connected with pipeline() so errors and early exits clean both sides up.
const producer = spawn(process.execPath, ['-e', 'for (let i = 1; i <= 5; i++) console.log("book-" + i)'], { stdio: ['ignore', 'pipe', 'inherit'] });
const consumer = spawn(process.execPath, ['-e', 'process.stdin.setEncoding("utf8").on("data", (s) => process.stdout.write(s.toUpperCase()))'], { stdio: ['pipe', 'pipe', 'inherit'] });

const lines = [];
await Promise.all([
  pipeline(producer.stdout, consumer.stdin),
  pipeline(consumer.stdout, new Writable({ write(chunk, _enc, done) { lines.push(chunk.toString()); done(); } })),
  once(producer, 'close'),
  once(consumer, 'close'),
]);
console.log(lines.join('').trim().split('\n'));
Output
[ 'BOOK-1', 'BOOK-2', 'BOOK-3', 'BOOK-4', 'BOOK-5' ]

Compare this with writing every chunk from ps.stdout into grep.stdin in a 'data' handler, as the documentation’s older example does: that loop ignores backpressure and error propagation, which pipeline() handles for you. The stream details are in Streams.

fork() and IPC done right

fork(modulePath, args, options) starts a new Node.js process running a script and opens an IPC channel to it. The parent uses child.send(message) and the 'message' event; the child uses process.send(message) and process.on('message', …​). It is the way to move CPU-bound work off the event loop when it must not share memory, or when it has to be killable. By default the child runs process.execPath with the parent’s process.execArgv; execPath and execArgv change that, silent or stdio change where its output goes (stdio, when an array, must contain exactly one 'ipc' entry), and shell is ignored. Every forked process has its own V8 instance and memory, so starting many is expensive: the documentation recommends against spawning a large number of them.

Messages and serialization

Messages are serialised before they cross the channel. The default is JSON, which cannot carry a BigInt (it throws), turns NaN into null, drops undefined and flattens a Date into a string and a Set into an empty object. serialization: 'advanced' uses the structured-clone family of the node:v8 module, which supports BigInt, Map, Set, ArrayBuffer, typed arrays, Buffer, Error, Date and RegExp; it is opt-in because it is not a superset of JSON and its speed depends on the data. Live objects such as sockets, streams or an HTTP request and response are not messages at all.

JSON versus advanced serialization
import { fork } from 'node:child_process';
import { once } from 'node:events';

async function roundTrip(value, serialization) {
  const child = fork(new URL('./echo-child.js', import.meta.url), { serialization });
  try {
    child.send(value);
    const [reply] = await once(child, 'message');
    return reply.received;
  } finally {
    child.disconnect();
    await once(child, 'exit');
  }
}

const sample = { n: NaN, undef: undefined, when: new Date(0), tags: new Set(['a']), map: new Map([[1, 2]]), big: 10n };

// JSON (the default) cannot carry BigInt at all and silently flattens the rest.
console.log('json    :', await roundTrip({ n: NaN, undef: undefined, when: new Date(0), tags: new Set(['a']) }, 'json'));
await roundTrip({ big: 10n }, 'json').catch((err) => console.log('json big:', err.name, err.message));
// 'advanced' uses the structured-clone family of types.
console.log('advanced:', await roundTrip(sample, 'advanced'));
Output
json    : { n: null, when: '1970-01-01T00:00:00.000Z', tags: {} }
json big: TypeError Do not know how to serialize a BigInt
advanced: {
  n: NaN,
  undef: undefined,
  when: 1970-01-01T00:00:00.000Z,
  tags: Set(1) { 'a' },
  map: Map(1) { 1 => 2 },
  big: 10n
}

The next program shows what happens when a live object is sent anyway: the default JSON path silently delivers a plain copy of the object’s enumerable fields (the child does not get a socket), and 'advanced' refuses it. Passing request and response objects between processes is therefore a bug, not a technique; pass the data you need, or hand over the socket itself with the handle argument (below).

Live objects are not messages
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { createServer, connect } from 'node:net';

const server = createServer().listen(0, '127.0.0.1');
await once(server, 'listening');
const socket = connect(server.address().port, '127.0.0.1');
await once(socket, 'connect');

// Live objects (sockets, streams, request and response objects) are not messages.
const json = fork(new URL('./echo-child.js', import.meta.url));
json.send({ socket });
const [reply] = await once(json, 'message');
console.log('json    : the child received a plain object with', Object.keys(reply.received.socket).length, 'copied fields, not a socket');
json.disconnect();
await once(json, 'exit');

const advanced = fork(new URL('./echo-child.js', import.meta.url), { serialization: 'advanced' });
try {
  advanced.send({ socket });
} catch (err) {
  console.log('advanced:', err.name, '- could not be cloned:', err.message.trimEnd().endsWith('could not be cloned.'));
}
advanced.disconnect();
await once(advanced, 'exit');
socket.destroy();
server.close();
Output
json    : the child received a plain object with 15 copied fields, not a socket
advanced: Error - could not be cloned: true

Messages whose cmd property starts with NODE_ are reserved for Node.js itself and are not delivered to your 'message' handler: choose another property name for your protocol.

The channel keeps processes alive, and lets you shut them down

The IPC channel has life-cycle consequences that cause most bugs. A forked child starts with the channel unreferenced; as soon as it registers a 'message' or 'disconnect' listener the channel is referenced and keeps the child alive. A child that waits for messages therefore never exits on its own until the channel closes. The parent can close it with child.disconnect() (the child’s 'disconnect' handler can then exit), or end the child with kill(). A child must also notice that its parent vanished: when the parent dies the channel closes and the child gets 'disconnect'.

A forked child lingers until the channel is closed
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';

const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };

// A child with a 'message' listener and an open channel does not exit by itself.
const lingering = fork(new URL('./sleepy-child.js', import.meta.url));
lingering.send('hello');
await once(lingering, 'message');
await sleep(500);
console.log('no disconnect  : still alive =', alive(lingering.pid), '| connected =', lingering.connected);

// Closing the channel lets the child exit by itself (its code has no other work).
lingering.disconnect();
await once(lingering, 'exit');
console.log('after disconnect: alive =', alive(lingering.pid));
sleepy-child.js
// Deliberately has no 'disconnect' handler: it only exits when the channel closes and nothing else holds it open.
process.on('message', (msg) => process.send(`echo ${msg}`));
Output
no disconnect  : still alive = true | connected = true
after disconnect: alive = false

Starting one child per request is the classic mistake here: if the child never disconnects or exits, every request leaks a process and the machine runs out of memory and process ids. A fork is a heavy object (a whole new V8 instance and event loop), so the pattern is a bounded pool of long-lived workers, with a queue in front of it that refuses work when it is full, a timeout per task, replacement of crashed workers, and an orderly shutdown.

Flow control

child.send() returns false when the channel is closed or when the backlog of unsent messages is large. It also accepts a callback, called with null or an Error once the message was handed to the channel. If a message cannot be sent and no callback is given, an 'error' event is emitted, for example when the child has already exited, so always attach an 'error' listener to a forked child.

Slowing down when send() returns false
import { fork } from 'node:child_process';
import { once } from 'node:events';

const child = fork(new URL('./echo-child.js', import.meta.url));
let received = 0;
child.on('message', () => { received++; });

// send() returns false when the backlog of unsent messages is large: wait before sending more.
let sent = 0;
let paused = 0;
const payload = 'x'.repeat(64 * 1024);
for (let i = 0; i < 400; i++) {
  const ok = child.send(payload);
  sent++;
  if (!ok) {
    paused++;
    await new Promise((resolve) => setImmediate(resolve)); // let the channel drain (a real app waits on callbacks/replies)
  }
}
while (received < sent) await new Promise((resolve) => setTimeout(resolve, 10));
console.log({ sent, received, timesToldToSlowDown: paused > 0 });

child.disconnect();
await once(child, 'exit');
Output (observed)
{ sent: 400, received: 400, timesToldToSlowDown: true }

Passing a socket (POSIX)

The second argument of send() can be a TCP server, a socket (and, since v26.10.0, a net.BoundSocket). The receiving 'message' handler gets it as a second argument. Sending sockets over IPC is not supported on Windows. Create the server with pauseOnConnect: true so no bytes are read in the parent before the socket moves. This is the mechanism behind the sticky-session pattern in Cluster & Scaling.

Handing a connection to a child
import { fork } from 'node:child_process';
import { createServer, connect } from 'node:net';
import { once } from 'node:events';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

const dir = mkdtempSync(join(tmpdir(), 'cp-handle-'));
const childFile = join(dir, 'handler.js');
writeFileSync(childFile, `
process.on('message', (msg, socket) => {
  if (msg === 'socket') { socket.end('handled by child ' + process.pid + '\\n'); }
});
process.on('disconnect', () => process.exit(0));
`);

const child = fork(childFile);
const server = createServer({ pauseOnConnect: true }, (socket) => {
  // pauseOnConnect: nothing has been read yet, so no data is lost when the socket moves.
  child.send('socket', socket);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');

const client = connect(server.address().port, '127.0.0.1');
client.setEncoding('utf8');
console.log((await once(client, 'data'))[0].replace(/\d+/, '<pid>').trim());

server.close();
child.disconnect();
await once(child, 'exit');
Output
handled by child <pid>

A bounded pool of worker processes

The worker answers task messages and does CPU-bound work (a deliberately slow Fibonacci); the disconnect handler makes it exit when the channel closes, so a crashed parent leaves no orphans.

worker.js
// CPU-heavy work in a separate process. Started with fork(); speaks a tiny request/response protocol.
function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

process.on('message', (msg) => {
  switch (msg.type) {
    case 'task':
      process.send({ type: 'result', id: msg.id, value: fib(msg.n), pid: process.pid });
      break;
    case 'crash': // used by the demonstration only
      process.exit(1);
      break;
  }
});

// With an IPC channel open and a 'message' listener, the process stays alive. When the parent
// goes away (even by crashing) the channel closes: exit instead of lingering as an orphan.
process.on('disconnect', () => process.exit(0));

The pool keeps a fixed number of workers, a bounded queue, a timeout per task (a stuck CPU loop cannot be asked to stop, so the worker is killed with SIGKILL and replaced), replacement of crashed workers (the in-flight task is rejected, the rest continue), and a close() that stops accepting work, disconnects every worker and escalates to SIGKILL after a grace period.

pool.js
import { fork } from 'node:child_process';
import { once } from 'node:events';
import { availableParallelism } from 'node:os';

const WORKER = new URL('./worker.js', import.meta.url);

/** A fixed-size pool of forked workers with a bounded queue, per-task timeouts and orderly shutdown. */
export class ProcessPool {
  #size;
  #maxQueue;
  #taskTimeoutMs;
  #workers = new Set(); // { child, busy: task | null }
  #queue = [];
  #nextId = 1;
  #closing = false;

  constructor({ size = Math.max(1, availableParallelism() - 1), maxQueue = 100, taskTimeoutMs = 5_000 } = {}) {
    this.#size = size;
    this.#maxQueue = maxQueue;
    this.#taskTimeoutMs = taskTimeoutMs;
    for (let i = 0; i < size; i++) this.#spawnWorker();
  }

  #spawnWorker() {
    const child = fork(WORKER, { serialization: 'json' });
    const worker = { child, task: null };
    this.#workers.add(worker);

    child.on('message', (msg) => {
      if (msg.type !== 'result' || !worker.task) return;
      const { task } = worker;
      worker.task = null;
      clearTimeout(task.timer);
      task.resolve(msg.value);
      this.#drain();
    });

    // 'exit' covers crashes and our own kills; 'error' covers failed spawns and failed send().
    child.on('error', () => {});
    child.on('exit', (code, signal) => {
      this.#workers.delete(worker);
      if (worker.task) {
        clearTimeout(worker.task.timer);
        worker.task.reject(new Error(`worker ${child.pid} ended (code ${code}, signal ${signal}) during a task`));
        worker.task = null;
      }
      if (!this.#closing) {
        this.#spawnWorker(); // replace it, keep the pool at full strength
        this.#drain();
      }
    });
    return worker;
  }

  #drain() {
    for (const worker of this.#workers) {
      if (this.#queue.length === 0) return;
      if (worker.task || !worker.child.connected) continue;
      const task = this.#queue.shift();
      worker.task = task;
      task.timer = setTimeout(() => {
        task.reject(new Error(`task ${task.id} timed out after ${this.#taskTimeoutMs} ms`));
        worker.task = null; // already rejected; the 'exit' handler must not reject twice
        worker.child.kill('SIGKILL'); // a stuck CPU loop cannot be asked politely
      }, this.#taskTimeoutMs);
      worker.child.send({ type: 'task', id: task.id, n: task.n }, (err) => {
        if (err) { clearTimeout(task.timer); worker.task = null; task.reject(err); }
      });
    }
  }

  run(n) {
    if (this.#closing) return Promise.reject(new Error('pool is closed'));
    if (this.#queue.length >= this.#maxQueue) return Promise.reject(new Error('queue is full'));
    return new Promise((resolve, reject) => {
      this.#queue.push({ id: this.#nextId++, n, resolve, reject, timer: null });
      this.#drain();
    });
  }

  get pids() { return [...this.#workers].map((w) => w.child.pid); }
  crashOne() { [...this.#workers][0].child.send({ type: 'crash' }); }

  /** Stop accepting work, then close every channel; escalate to SIGKILL after the grace period. */
  async close({ graceMs = 2_000 } = {}) {
    this.#closing = true;
    for (const task of this.#queue.splice(0)) task.reject(new Error('pool closed before the task started'));
    const exits = [...this.#workers].map(({ child }) => {
      if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
      const done = once(child, 'exit');
      child.disconnect(); // the worker's 'disconnect' handler makes it exit by itself
      const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
      return done.finally(() => clearTimeout(timer));
    });
    await Promise.all(exits);
  }
}

if (import.meta.main) {
  const pool = new ProcessPool({ size: 2, maxQueue: 3, taskTimeoutMs: 3_000 });
  try {
    console.log('workers:', pool.pids.length);

    // 2 running + 3 queued is the limit: the sixth task is refused immediately.
    const results = await Promise.allSettled([35, 35, 30, 30, 30, 30].map((n) => pool.run(n)));
    console.log(results.map((r) => (r.status === 'fulfilled' ? r.value : r.reason.message)));

    // A crash fails only the task that was running; the pool replaces the process.
    const before = pool.pids;
    pool.crashOne();
    await new Promise((resolve) => setTimeout(resolve, 300));
    console.log('replaced a crashed worker:', pool.pids.length === 2 && pool.pids.some((p) => !before.includes(p)));
    console.log('after crash:', await pool.run(20));
  } finally {
    await pool.close(); // always: otherwise the open IPC channels keep this process alive
  }
}
Output (identical on Node.js 26, 24 and 22)
workers: 2
[ 9227465, 9227465, 832040, 832040, 832040, 'queue is full' ]
replaced a crashed worker: true
after crash: 6765

A second program shows the timeout: the task needs far longer than its 400 ms budget, so its worker is killed and replaced, and the next task succeeds.

A task that exceeds its budget
import { ProcessPool } from './pool.js';

// n = 50 would take far longer than the 400 ms budget: the worker is killed and replaced.
const pool = new ProcessPool({ size: 1, taskTimeoutMs: 400 });
try {
  const started = pool.pids[0];
  console.log(await pool.run(50).catch((err) => err.message));
  await new Promise((resolve) => setTimeout(resolve, 200));
  console.log('worker replaced:', pool.pids[0] !== started, '| next task:', await pool.run(10));
} finally {
  await pool.close();
}
Output
task 1 timed out after 400 ms
worker replaced: true | next task: 55

After each run no worker.js process was left behind (checked with ps, observed). The rules this pool encodes are the same you would apply to any process-based design:

  • Bound everything: workers, queue length, task time, message size and output size.

  • Always handle 'error' and 'exit' on every child, and decide in one place whether an exit is normal, a crash to replace, or a shutdown.

  • Reject an in-flight task exactly once, whether the cause is a timeout, a crash or a closing pool.

  • Close the channel (disconnect()) on shutdown and give workers a chance to exit before SIGKILL; otherwise the open channels keep the parent alive too.

  • Prefer worker threads (Worker Threads) when you need shared memory or cheaper start-up, and processes when you need isolation, a different executable, a separate memory limit or a worker you can kill outright.

Child processes and the permission model

With node --permission, starting a child process is denied unless --allow-child-process is passed, in which case Node.js prints a security warning, because a child is not bound by the parent’s restrictions. process.permission.has('child') tells a program whether it may. The flag also guards fork().

A denied spawn under --permission
import { spawnSync } from 'node:child_process';

// Run this file with: node --permission --allow-fs-read=* e18-permission.js
console.log('child process allowed:', process.permission.has('child'));
try {
  spawnSync(process.execPath, ['-p', '1']);
} catch (err) {
  console.log(err.code, '|', err.permission);
}
Run and output
node --permission --allow-fs-read='*' e18-permission.js
child process allowed: false
ERR_ACCESS_DENIED | ChildProcess

The model is a seat belt for code you trust, not a sandbox for hostile code; see Permission Model and Security Best Practices.

Bookshelf: running tools safely

Bookshelf has housekeeping steps that call external programs (a cover-thumbnail converter, a nightly backup tool). Each follows the same recipe: a program chosen from an allowlist, an argument array, an explicit environment, a timeout, bounded output and a cancellation signal. The wrapper below builds those defaults on the run() helper from Events: spawn, error, exit and close. Its allowlist holds one harmless entry (the Node.js version), and a real application would list its own tools there. Command-line programs of this kind are described in Building CLI Tools.

A hardened wrapper for external tools
import { run } from './e03-run-helper.js';

const ALLOWED_TOOLS = new Map([['node-version', [process.execPath, ['--version']]]]);

export async function runTool(name, { signal } = {}) {
  const entry = ALLOWED_TOOLS.get(name); // allowlist: no free-form command from the caller
  if (!entry) throw new TypeError(`unknown tool: ${name}`);
  const [file, args] = entry;
  const result = await run(file, args, {
    env: { PATH: process.env.PATH }, // nothing else is inherited
    signal: signal ?? AbortSignal.timeout(10_000), // every run is bounded in time
    maxBytes: 64 * 1024, // and in output size
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  if (result.code !== 0) throw new Error(`${name} failed with code ${result.code}: ${result.stderr.trim()}`);
  return result.stdout.trim();
}

console.log(await runTool('node-version'));
await runTool('rm-rf').catch((err) => console.log(err.message));

What changed recently

Table 8. Child process changes by Node.js version
Version Change

v26.10.0

subprocess.send() can send a net.BoundSocket as the handle (its TCP handle moves to the child).

v26.8.2

DEP0209, documentation-only: using an AbortSignal to dispose of long-lived resources is deprecated; the child_process docs repeat the advice for the signal option and point to using.

v25.4.0 and v24.14.0

util.convertProcessSignalToExitCode() added.

v25.0.0

--allow-net added to the permission model (Active development); --allow-child-process has existed since v20.0.0.

v24.2.0

subprocess[Symbol.dispose]() is no longer experimental.

v24.0.0

DEP0190 becomes a runtime deprecation: passing args with the shell option prints a warning.

v23.11.0 and v22.15.0

DEP0190 as a documentation-only deprecation.

April 2024 security releases

CVE-2024-27980: .bat and .cmd files passed to spawn() or spawnSync() without a shell throw EINVAL on Windows.

v20.5.0 and v18.18.0

subprocess[Symbol.dispose]() added (experimental at the time).

v15.x

AbortSignal support (v15.4.0 to v15.6.0), timeout and killSignal for spawn() and fork() (v15.13.0, v15.11.0), the 'spawn' event (v15.1.0).

Where the books are outdated

The child-process chapter of Efficient Node.js (Samer Buna, O’Reilly 2025) is a useful tour of spawn(), exec(), execFile(), the stdio options, detaching and forking, and it is worth reading next to this page. Apply these updates and corrections while following it:

Table 9. Points to check when following the book
Topic Current behaviour

Passing args together with shell: true

Deprecated as DEP0190 (runtime warning since v24.0.0). The array is only concatenated, not escaped; see Shell injection and DEP0190.

Building command strings from user input for exec()

Remains a shell-injection hole in any Node.js version. Use execFile() or spawn() with an argument array and validate input; see Shell injection and DEP0190.

Launching .bat and .cmd files with spawn() or execFile()

Since the April 2024 security releases (CVE-2024-27980) it throws EINVAL on Windows without a shell, and the documented workarounds are different; see Windows: .bat and .cmd files.

A new fork() for every request

Leaks a process per request when the child does not exit, and starts a heavy process each time. Use a bounded pool with timeouts, replacement and shutdown; see fork() and IPC done right.

Sending request and response objects (or any live object) over IPC

They are not serialisable: the JSON path copies fields and 'advanced' refuses; pass data, or pass a socket handle; see fork() and IPC done right.

Node.js baseline

The book is written against Node.js 22. Later changes on this page (the DEP0190 runtime warning in v24.0.0, util.convertProcessSignalToExitCode(), the stable Symbol.dispose in v24.2.0, net.BoundSocket handles in v26.10.0) postdate it.

Node.js Projects (Jonathan Wexler, O’Reilly 2025) builds applications and does not treat child processes in depth; use the API reference for the points above.

Pitfalls and good practice

Table 10. Checklist for child processes
Practice Why

Use execFile() or spawn() with an argument array

No shell parses the input, so shell metacharacters are data. Never combine args with shell.

Reject or end options for untrusted values

Programs parse their own options even without a shell: use -- and an allowlist.

Handle 'error' on every child

A missing executable, a failed kill() or a failed send() emit 'error'; with no listener Node.js throws.

Wait for 'close' to read output, 'exit' to know the process ended

'exit' can fire while stdio is still open.

Consume or ignore every pipe

An unread pipe fills and blocks the child.

Bound time and output

timeout, signal, maxBuffer and your own byte count.

Build env explicitly

The default hands the child every secret of the parent.

Disconnect and kill on shutdown

An open IPC channel or a detached child keeps processes (and the parent) alive.

Do not use the sync variants in servers

They block the event loop for the child’s whole run.

Do not parse human-oriented output

Prefer a tool’s machine-readable mode (JSON) or a library; output formats differ between platforms and versions.

References