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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Added in v0.1.90, v0.1.90, v0.1.91 and v0.5.0. |
|
2 Stable |
Added in v0.11.12. The |
|
2 Stable |
The class was added in v2.2.0. The |
|
2 Stable |
Calls |
|
2 Stable |
Returns a boolean for flow control since v5.0.0. Sending a |
|
2 Stable |
Added in v13.2.0 and v12.16.0. The default is |
|
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 |
Deprecated, documentation-only |
Since v26.8.2. |
|
Stable |
Added in v25.4.0 and v24.14.0, so absent on Node.js 22 (observed). |
|
2 Stable |
Added in v20.0.0. The |
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.
| Function | Starts | Output | Use it for |
|---|---|---|---|
|
The program directly, with an argument array; no shell unless |
Streams: |
Anything long-running or with a lot of output; the building block of the other three |
|
The program directly, with an argument array; no shell by default |
Buffered, passed to the callback (or the promisified result), limited by |
Short programs with small output when you want a promise or callback; the safe default for "run this tool" |
|
A shell ( |
Buffered, limited by |
Shell features (pipes, redirection, globs) with a string you fully control; never with user input |
|
A new Node.js process running a script, with an IPC channel |
Inherited from the parent unless |
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.
Source: Node.js API: Child process
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.
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 });
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:
| Member | Meaning |
|---|---|
|
The process id, or |
|
The parent’s ends of the pipes. They are |
|
|
|
|
|
Sends |
|
What was actually executed; useful in logs. |
|
The IPC channel, present when the child was started with |
|
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 |
|---|---|
|
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 |
|
The process could not be spawned, could not be killed, a message could not be sent, or the |
|
The child has ended. You get |
|
The child has ended and its stdio streams are closed. It always comes after |
|
The IPC channel was closed by either side. |
|
The child called |
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.
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'));
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.)
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));
}
{ 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.
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));
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:
| Option | Default | Meaning |
|---|---|---|
|
|
Working directory; a string or a |
|
|
The whole environment of the child; see Working directory, environment and identity. |
|
|
Use |
|
|
Milliseconds after which |
|
|
Signal for a timeout or an abort. |
|
|
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. |
|
none |
An |
|
|
Which shell interprets the command. With |
|
none |
Run the child as another user or group (POSIX). |
|
|
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.
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));
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 noshelloption. Do not build the array by splitting a string. -
Never interpolate input into
exec()orexecSync(). 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
-lor--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.
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));
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.
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');
(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.
Source: Node.js API: DEP0190
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.
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) => { /* ... */ });
Source: Node.js API: Spawning .bat and .cmd files on Windows, Node.js blog: April 10, 2024 Security Releases (CVE-2024-27980)
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.
| Value | Meaning |
|---|---|
|
A pipe whose parent end is |
|
Like |
|
The child gets |
|
The child uses the parent’s descriptor: its output appears in the parent’s terminal. In a position above 2 it behaves like |
|
Creates the message channel used by |
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. |
|
The default: a pipe for 0, 1 and 2, |
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');
inherit: written by the child
ignore : stdout is null
pipes : "BOOKSHELF\n"
fd : "to log\nalso to log\n"
shared stderr
Source: Node.js API: options.stdio
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.
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));
{"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.
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 });
}
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:SIGTERMcan be caught, so a robust parent waits for'exit'and escalates toSIGKILLafter a grace period, as the last case does.child.killedonly 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()andexecFile()rejections for a timeout havekilled: trueand the signal name; forspawn()use the'exit'event. -
DEP0209 (documentation-only, v26.8.2) deprecates using an
AbortSignalas a way to dispose of a long-lived resource. For children, keepsignalfor cancellation, propagated aborts and timeouts, and useusing(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 anENOSYSerror and a name that does not exist on Windows throwsERR_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.
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));
}
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.
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) });
child was disposed
{ code: null, signal: 'SIGTERM', shellStyle: 143 }
Source: Node.js API: subprocess[Symbol.dispose()], Node.js API: util.convertProcessSignalToExitCode()
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.
| Setting | Effect |
|---|---|
|
The child becomes the leader of a new process group and session ( |
|
The child can keep running after the parent exits and gets its own console window. |
|
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. |
|
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.
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();
}
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).
| Function | What it does on failure |
|---|---|
|
Never throws for a non-zero exit. Returns an object with |
|
Throw when the child exits non-zero or times out; the error carries the full result of the underlying |
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.
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());
{
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.
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'));
[ '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.
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'));
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).
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();
json : the child received a plain object with 15 copied fields, not a socket
advanced: Error - could not be cloned: true
Source: Node.js API: subprocess.send()
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'.
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));
// 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}`));
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.
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');
{ sent: 400, received: 400, timesToldToSlowDown: true }
Source: Node.js API: subprocess.send()
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.
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');
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.
// 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));
Source: Node.js API: process.send()
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.
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
}
}
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.
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();
}
task 1 timed out after 400 ms
worker replaced: true | next task: 55
Source: Node.js API: subprocess.kill()
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 beforeSIGKILL; 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().
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);
}
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.
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
| Version | Change |
|---|---|
v26.10.0 |
|
v26.8.2 |
DEP0209, documentation-only: using an |
v25.4.0 and v24.14.0 |
|
v25.0.0 |
|
v24.2.0 |
|
v24.0.0 |
DEP0190 becomes a runtime deprecation: passing |
v23.11.0 and v22.15.0 |
DEP0190 as a documentation-only deprecation. |
April 2024 security releases |
CVE-2024-27980: |
v20.5.0 and v18.18.0 |
|
v15.x |
|
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:
| Topic | Current behaviour |
|---|---|
Passing |
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 |
Remains a shell-injection hole in any Node.js version. Use |
Launching |
Since the April 2024 security releases (CVE-2024-27980) it throws |
A new |
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 |
Node.js baseline |
The book is written against Node.js 22. Later changes on this page (the DEP0190 runtime warning in v24.0.0, |
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
| Practice | Why |
|---|---|
Use |
No shell parses the input, so shell metacharacters are data. Never combine |
Reject or end options for untrusted values |
Programs parse their own options even without a shell: use |
Handle |
A missing executable, a failed |
Wait for |
|
Consume or ignore every pipe |
An unread pipe fills and blocks the child. |
Bound time and output |
|
Build |
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. |
Source: Node.js API: Child process
References
-
Node.js API: Child process, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: child_process.spawn(), https://nodejs.org/docs/latest/api/child_process.html#child_processspawncommand-args-options
-
Node.js API: child_process.exec(), https://nodejs.org/docs/latest/api/child_process.html#child_processexeccommand-options-callback
-
Node.js API: child_process.execFile(), https://nodejs.org/docs/latest/api/child_process.html#child_processexecfilefile-args-options-callback
-
Node.js API: child_process.fork(), https://nodejs.org/docs/latest/api/child_process.html#child_processforkmodulepath-args-options
-
Node.js API: Spawning .bat and .cmd files on Windows, https://nodejs.org/docs/latest/api/child_process.html#spawning-bat-and-cmd-files-on-windows
-
Node.js API: Synchronous process creation, https://nodejs.org/docs/latest/api/child_process.html#synchronous-process-creation
-
Node.js API: options.stdio, https://nodejs.org/docs/latest/api/child_process.html#optionsstdio
-
Node.js API: options.detached, https://nodejs.org/docs/latest/api/child_process.html#optionsdetached
-
Node.js API: subprocess.send(), https://nodejs.org/docs/latest/api/child_process.html#subprocesssendmessage-sendhandle-options-callback
-
Node.js API: Advanced serialization, https://nodejs.org/docs/latest/api/child_process.html#advanced-serialization
-
Node.js API: Deprecations, DEP0190, https://nodejs.org/docs/latest/api/deprecations.html#dep0190-passing-args-to-nodechild_process-execfilespawn-with-shell-option
-
Node.js API: Deprecations, DEP0209, https://nodejs.org/docs/latest/api/deprecations.html#dep0209-using-abortsignal-to-dispose-of-resources
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html#process-based-permissions
-
Node.js API: Command-line options, --allow-child-process, https://nodejs.org/docs/latest/api/cli.html#--allow-child-process
-
Node.js API: util.convertProcessSignalToExitCode(), https://nodejs.org/docs/latest/api/util.html#utilconvertprocesssignaltoexitcodesignal
-
Node.js API: process.kill(), https://nodejs.org/docs/latest/api/process.html#processkillpid-signal
-
Node.js API: stream.pipeline(), https://nodejs.org/docs/latest/api/stream.html#streampipelinesource-transforms-destination-options
-
Node.js API: events.once(), https://nodejs.org/docs/latest/api/events.html#eventsonceemitter-name-options
-
Node.js API: net.createServer(), https://nodejs.org/docs/latest/api/net.html#netcreateserveroptions-connectionlistener
-
Node.js API: AbortSignal, https://nodejs.org/docs/latest/api/globals.html#class-abortsignal
-
Node.js API: process.send(), https://nodejs.org/docs/latest/api/process.html#processsendmessage-sendhandle-options-callback
-
Node.js API: process.permission.has(), https://nodejs.org/docs/latest/api/process.html#processpermissionhasscope-reference
-
Node.js API: ERR_ACCESS_DENIED, https://nodejs.org/docs/latest/api/errors.html#err_access_denied
-
Node.js Learn: Security Best Practices, https://nodejs.org/learn/getting-started/security-best-practices
-
Node.js blog: April 10, 2024 Security Releases, https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/