Process, Signals & Exit Codes
|
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-03, and every example below was run on all three lines unless the text says otherwise. The examples were run on macOS (arm64), so a few observations are labelled as such: signal numbers, pipe buffer sizes and the way a shell reports a killed process differ between operating systems.
Every Node.js program is a process, and the global process object is the program’s handle on it: what it was started with, what it is
allowed to see, how much it uses, and how it ends. This page covers three connected topics. First the identity of the process:
arguments, environment variables, the working directory, the process id, the platform, memory and high-resolution time. Second the
ending of the process: exit codes, process.exitCode versus process.exit(), and the 'beforeExit' and 'exit' events. Third the
conversation with the outside world while the process runs: signals, graceful shutdown, and the last-resort error handlers. It closes
with the two newest corners of the object: process.getBuiltinModule() and process.features. How the loop decides that a process is
finished is explained in The Event Loop; the error classes and the error.code values that end up in
these handlers are in Errors & Error Handling; the language-level view of
exceptions is JavaScript Exception Handling. The running scenario is the
Bookshelf application that grows across this section, whose architecture is described in
Introduction & Architecture.
The stability and version map
| API or flag | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
Stable |
Present in every supported line. |
|
Stable |
Implicit conversion of non-string values is a documentation-only deprecation (DEP0104) since v10.0.0, with |
|
Stable |
No longer experimental in v24.10.0 and v22.21.0 (added in 20.6, 21.7 and 22.9 depending on the item). |
|
Stable |
|
|
Stable |
Stable since v24.0.0 and v22.16.0 (experimental before); |
|
Stable |
Added in v10.7.0. The older |
|
Stable |
Since v20.0.0 only integers or integer strings are accepted (DEP0164, End-of-Life in v20.0.0). |
|
Stable |
|
Signal events ( |
Stable |
Not available in worker threads. Windows supports only a small subset. |
|
Stable |
Added in v23.7.0 and v22.14.0; no longer experimental in v24.8.0 and v22.20.0. |
|
Stable |
No longer experimental in v13.12.0 and v12.17.0; default signal |
|
Stable |
Added in v22.3.0 and v20.16.0. |
|
Stable |
|
|
1.2 Release candidate |
Added in v23.0.0 and v22.10.0; type stripping is stable since v25.2.0 and v24.12.0; the |
|
0 Deprecated |
Deprecated in v23.4.0 and v22.13.0: |
|
1.1 Active development |
Added in v22.5.0. |
|
1 Experimental |
Added in v23.11.0 and v22.15.0; since v26.1.0 a failing |
|
1 Experimental |
Added in v25.9.0. |
|
Follows the permission model (2 Stable) |
Added in v20.0.0; |
|
0 Deprecated |
DEP0138 (use |
What the process object is
process is a global, an instance of EventEmitter (see Events & EventEmitter), and the
module node:process exports the same object. Prefer the import in modules that you want to read like a dependency list; the global
is identical. Underneath, lib/process.js and its siblings are the JavaScript layer, and the C++ bindings call into the operating
system through libuv for the parts that must be portable: the signal watchers, the high-resolution clock, the memory queries
(process.availableMemory() and process.constrainedMemory() are documented in terms of libuv’s uv_get_available_memory and
uv_get_constrained_memory) and the exit path. Two consequences are worth keeping in mind. The object describes one operating system
process, so in a worker thread (see Worker Threads) several members change meaning:
process.exit() stops the worker thread, not the process; signal events are not available; process.env is a copy; and a few
POSIX identity functions such as process.initgroups() are not available. And it is the one object that mixes read-only facts
(pid, platform), live measurements (memoryUsage()), settings (exitCode, title) and actions (kill(), exit()), so the
sections below group it by purpose.
The life of a process, with the points where the events of this page fire, looks like this.
Identity: arguments, directory, ids, platform
process.argv is the array of command-line arguments. Element 0 is process.execPath (the absolute path of the node executable, with
symbolic links resolved), element 1 is the absolute path of the entry script when there is one, and the user’s own arguments start at
index 2. process.argv0 keeps the original, unresolved value of argv[0] as typed. Everything that precedes the script name and is
meant for Node.js itself (--watch, --env-file=.env, --import) lands in process.execArgv instead, which is exactly what
you pass on when you start a child process "with the same options". Arguments are always strings: PORT=3000 style parsing is your
job, or use util.parseArgs() as shown in Building CLI Tools.
import process, { argv, argv0, execArgv, execPath, cwd, pid, ppid, platform, arch, uptime, versions } from 'node:process';
console.log('argv :', argv.slice(2));
console.log('argv[0] is execPath:', argv[0] === execPath, '| argv0:', argv0);
console.log('argv[1] is absolute:', argv[1].startsWith('/'), '| ends with', argv[1].split('/').at(-1));
console.log('execArgv :', execArgv);
console.log('cwd :', cwd().split('/').at(-1));
console.log('pid/ppid :', typeof pid, typeof ppid, pid !== ppid);
console.log('platform :', platform, arch);
console.log('node / v8 / uv:', versions.node.split('.')[0], versions.v8.split('.')[0], versions.uv);
console.log('uptime < 1s:', uptime() < 1);
console.log('release :', process.release.name, process.release.lts ?? '(not LTS)');
v8 and uv shows 24 and 22 as noted below)argv : [ 'one', '--two=3' ]
argv[0] is execPath: true | argv0: node
argv[1] is absolute: true | ends with info.js
execArgv : [ '--no-warnings' ]
cwd : proc
pid/ppid : number number true
platform : darwin arm64
node / v8 / uv: 26 14 1.52.1
uptime < 1s: true
release : node (not LTS)
The command was node --no-warnings info.js one --two=3. On Node.js 24 the version line reads 24 13 1.52.1 and process.release.lts is
Krypton; on Node.js 22 it reads 22 12 1.51.0 and Jod. process.release.lts is absent on a Current line, which is why the example
falls back to a literal. For the full table of what a Node.js build reports see process.versions in the same API page.
| Member | Meaning | Watch out for |
|---|---|---|
|
Process id and the id of its parent. Useful in logs and for |
The pid belongs to the process, not to your container; inside a container it is often 1 (see containers). |
|
The platform Node.js was compiled for: |
It is |
|
The title shown by |
Platforms limit the length; on Linux and macOS it is bounded by the binary name plus the command-line arguments. |
|
Seconds since the process started, with fractions. |
It measures the process, not the application: a worker or a restarted container resets it. |
|
Numeric identities. |
POSIX only: they are |
|
The three standard streams, each with an |
Writes are synchronous for files, asynchronous for pipes on POSIX and for terminals on Windows, which is the root of the |
import { stdin, stdout, stderr } from 'node:process';
// Decide behaviour from where the streams point, not from guesses
console.log(JSON.stringify({
stdinTTY: Boolean(stdin.isTTY),
stdoutTTY: Boolean(stdout.isTTY),
stderrTTY: Boolean(stderr.isTTY),
fds: [stdin.fd, stdout.fd, stderr.fd],
}));
true for the three flags){"stdinTTY":false,"stdoutTTY":false,"stderrTTY":false,"fds":[0,1,2]}
Programs that print colours, progress bars or prompts should check isTTY first, which is also what util.styleText() does when it decides
whether to emit escape codes. The CLI side of this is the subject of Console, Readline & Terminal I/O.
The working directory
process.cwd() returns the directory the process was started in, which is where relative paths such as ./data.csv resolve. It is
a property of how the process was launched (a shell, a service manager, a container WORKDIR), not of where the script lives, and
process.chdir() changes it for the whole process, so one library calling it silently breaks every relative path elsewhere in the
program. The robust habit is to anchor files to the module with import.meta.dirname and import.meta.filename (stable in ES modules;
the CommonJS equivalents are dirname and filename, see ECMAScript Modules) and to
treat cwd() as user input: the directory the user is standing in.
import { chdir, cwd } from 'node:process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
console.log('cwd :', cwd().split('/').at(-1));
console.log('script directory:', import.meta.dirname.split('/').at(-1));
chdir('..');
console.log('after chdir("..") :', cwd().split('/').at(-1));
// Relative paths follow the cwd, so this file is no longer found...
try {
readFileSync('./cwd.js', 'utf8');
} catch (err) {
console.log('relative read :', err.code);
}
// ...while a path anchored to the module still is.
console.log('anchored read :', readFileSync(resolve(import.meta.dirname, 'cwd.js'), 'utf8').length > 0);
cwd : proc
script directory: proc
after chdir("..") : bookshelf
relative read : ENOENT
anchored read : true
Under the permission model (see The Permission Model) the directory you start in also decides which relative paths you can reach, which is one more reason to start services from a known directory.
Environment variables
process.env is an object that exposes the environment the process was started with. It looks like an ordinary object and behaves like a
thin wrapper over the C-level environment, with rules that surprise people coming from objects:
-
Values are always strings. Assigning a number, a boolean,
nullorundefinedstores its string form, soenv.DEBUG_SQL = falsestores the truthy string"false"andenv.X = undefinedstores the text"undefined". The documentation calls the implicit conversion deprecated (DEP0104, documentation-only since v10.0.0) and says a future version may throw for anything that is not a string, number or boolean. -
A missing variable is
undefined, and the only way to remove one isdelete env.NAME. -
Changes stay in the process and its children. They are inherited by child processes started afterwards (unless you pass your own
envoption) but never reach the parent shell:node -e 'process.env.foo = "bar"' && echo $fooprints an empty line. -
Names are case-insensitive on Windows only; on every other platform
PathandPATHare different variables. -
Worker threads get a copy of the parent’s
process.envby default (since v11.14.0), configurable with theenvoption ofWorker; only the main thread can change the real environment.
import { env, loadEnvFile } from 'node:process';
import { spawnSync } from 'node:child_process';
// 1. Values are always strings
env.RETRIES = 3;
env.DEBUG_SQL = false;
console.log(typeof env.RETRIES, JSON.stringify(env.RETRIES), JSON.stringify(env.DEBUG_SQL));
console.log('"false" is truthy:', Boolean(env.DEBUG_SQL));
// 2. Missing keys are undefined; use delete, never assign undefined
env.TOKEN_HINT = undefined;
console.log('assigned undefined ->', JSON.stringify(env.TOKEN_HINT));
delete env.TOKEN_HINT;
console.log('after delete ->', env.TOKEN_HINT, '| in:', 'TOKEN_HINT' in env);
// 3. Parse deliberately, with a default
const port = Number.parseInt(env.PORT ?? '3000', 10);
const verbose = env.VERBOSE === '1' || env.VERBOSE === 'true';
console.log({ port, verbose });
// 4. Changes are visible to children, never to the parent shell
env.BOOKSHELF_MODE = 'child-sees-this';
const child = spawnSync(process.execPath, ['-p', 'process.env.BOOKSHELF_MODE'], { encoding: 'utf8' });
console.log('child printed:', child.stdout.trim());
// 5. Pass an explicit environment to a child instead of mutating your own
const clean = spawnSync(process.execPath, ['-p', '[process.env.ONLY_THIS, process.env.HOME].join()'], {
encoding: 'utf8',
env: { PATH: env.PATH, ONLY_THIS: '1' },
});
console.log('clean child sees ONLY_THIS,HOME ->', clean.stdout.trim());
string "3" "false"
"false" is truthy: true
assigned undefined -> "undefined"
after delete -> undefined | in: false
{ port: 3000, verbose: false }
child printed: child-sees-this
clean child sees ONLY_THIS,HOME -> 1,
The last line shows the safe way to give a child a different environment: pass an explicit env to spawn() or spawnSync() (see
Child Processes) instead of mutating your own. Parse with Number.parseInt(value, 10) or an explicit
comparison, and prefer ?? over || for defaults, because || also replaces valid but falsy values such as the empty string.
Source: Node.js API: Process (process.env), Node.js Learn: How to read environment variables from Node.js
Loading a .env file without a package
Reading configuration from a file is built in. node --env-file=.env app.js loads the file before any of your code runs;
--env-file-if-exists does the same but continues silently when the file is missing; process.loadEnvFile(path) does it at run time
(default path ./.env, and NODE_OPTIONS inside the file has no effect); and util.parseEnv(text) only parses a string and returns the
object without touching process.env. All of them were experimental when they were introduced and are stable since v24.10.0 and v22.21.0.
The scratch project’s .env contains only placeholders (PORT=8080, DB_PATH=./bookshelf.db, GREETING="hello from a file"); real
secrets belong in your platform’s secret store, never in a committed file. Variables that are already set in the environment win over the
file, which is why a deployment can override a developer default.
import { env, loadEnvFile } from 'node:process';
import { parseEnv } from 'node:util';
console.log('before loadEnvFile:', env.GREETING);
loadEnvFile('.env'); // throws ENOENT if the file is missing
console.log('after loadEnvFile :', env.GREETING, env.PORT);
// parseEnv() only parses; it does not touch process.env
console.log(parseEnv('A=1\nB="two words"').B);
// Variables that already exist in the environment are not overwritten
env.PORT = '9999';
loadEnvFile('.env');
console.log('PORT stays :', env.PORT);
before loadEnvFile: undefined
after loadEnvFile : hello from a file 8080
two words
PORT stays : 9999
node --env-file=.env -p 'process.env.PORT + " " + process.env.DB_PATH' # 8080 ./bookshelf.db
node --env-file-if-exists=missing.env -p '"missing file ok"' # missing.env not found. Continuing without it.
node --env-file=missing.env -p 1; echo $? # node: missing.env: not found, then status 9
Source: Node.js API: Command-line options (--env-file), Node.js API: Command-line options (--env-file-if-exists)
The status 9 of the last command was observed on all three lines; it is the same "invalid argument" code listed in the table below.
The "variables already set win" rule for loadEnvFile() was observed (the example keeps 9999) and matches the documented behaviour of
--env-file.
Memory, CPU and time
process.memoryUsage() returns five numbers in bytes, and knowing what each one measures is the difference between reading a graph and
guessing at it:
| Field | Meaning |
|---|---|
|
Resident set size: the memory of the whole process held in RAM, including the V8 heap, the code, native objects and buffers. In a worker thread it is still the value for the entire process. |
|
V8’s allocated and used heap (JavaScript objects). In a worker thread they refer to that thread only. |
|
Memory of C++ objects tied to JavaScript objects managed by V8. |
|
Memory for |
process.memoryUsage.rss() returns only the resident set size and is documented as faster, because memoryUsage() iterates over memory
pages to collect its numbers. process.availableMemory() reports the free memory the process can still use and
process.constrainedMemory() the limit imposed by the operating system (a container cgroup, for example), or 0 when there is none or it is unknown.
process.cpuUsage() returns user and system CPU time in microseconds and process.resourceUsage() the fuller resource-usage statistics of the operating system.
import { memoryUsage, availableMemory, constrainedMemory, cpuUsage, resourceUsage } from 'node:process';
const mb = (bytes) => Math.round(bytes / 1024 / 1024) + 0;
const delta = (a, b) => Object.fromEntries(Object.keys(a).map((k) => [k, mb(b[k] - a[k])]));
const s0 = memoryUsage();
const buffer = Buffer.alloc(64 * 1024 * 1024, 1); // 64 MB outside the V8 heap
const s1 = memoryUsage();
const rows = Array.from({ length: 500_000 }, (_, i) => ({ id: i, title: `Book ${i}` }));
const s2 = memoryUsage();
console.log('64 MB Buffer, MB added :', delta(s0, s1));
const { heapUsed, external } = delta(s1, s2);
console.log('500k small objects, MB :', { heapUsed, external });
console.log('rss() matches memoryUsage().rss:', Math.abs(memoryUsage.rss() - memoryUsage().rss) < 32 * 1024 * 1024);
console.log('availableMemory() is a number :', typeof availableMemory() === 'number');
console.log('constrainedMemory() (0: no OS limit):', typeof constrainedMemory());
console.log('cpuUsage() keys :', Object.keys(cpuUsage()).join(', '), '(microseconds)');
console.log('resourceUsage() has maxRSS :', 'maxRSS' in resourceUsage());
console.log(buffer.length === 64 * 1024 * 1024, rows.length); // keep both reachable
64 MB Buffer, MB added : { rss: 64, heapTotal: 0, heapUsed: 0, external: 64, arrayBuffers: 64 }
500k small objects, MB : { heapUsed: 39, external: 0 }
rss() matches memoryUsage().rss: true
availableMemory() is a number : true
constrainedMemory() (0: no OS limit): number
cpuUsage() keys : user, system (microseconds)
resourceUsage() has maxRSS : true
true 500000
The Buffer lives outside the V8 heap: heapUsed did not move while external, arrayBuffers and rss grew by 64 MB. That is why a leak of
Buffer`s is invisible in a heap graph but obvious in `rss; the full diagnosis workflow is in
Memory & Leaks. The Node.js documentation also notes that on Linux systems using glibc a
long-running process can show rss growth with a flat heapTotal because of allocator fragmentation, and points to an alternative
allocator as the remedy.
High-resolution time
process.hrtime.bigint() returns the current high-resolution real time in nanoseconds as a bigint. It is relative to an arbitrary point in
the past, unrelated to the time of day, and therefore monotonic: it is not affected by clock drift or by someone changing the system
clock. Use it, or performance.now() (milliseconds with a fraction, monotonic, relative to the process start), to measure durations; use
Date.now() only when you need a calendar time. The old process.hrtime() that returns a [seconds, nanoseconds] pair is 3 Legacy: it
still works and its time argument allows a diff, but with bigint the difference is plain subtraction. Convert to milliseconds with
Number(elapsedNs) / 1e6 once the subtraction is done, never before, because a bigint cannot be mixed with a number in arithmetic.
import { hrtime } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { performance } from 'node:perf_hooks';
const t0 = hrtime.bigint(); // monotonic, nanoseconds, bigint
const wall0 = Date.now(); // wall clock, can jump when NTP or the user changes the clock
const p0 = performance.now(); // monotonic, milliseconds since process start, fractional
await sleep(50);
const elapsedNs = hrtime.bigint() - t0;
console.log('type :', typeof elapsedNs);
console.log('>= 49 ms :', elapsedNs >= 49_000_000n);
console.log('as milliseconds :', Number(elapsedNs) / 1e6 >= 49);
console.log('Date.now delta :', Date.now() - wall0 >= 49);
console.log('performance.now :', performance.now() - p0 >= 49);
// Legacy tuple form: [seconds, nanoseconds]
const [s, ns] = hrtime();
console.log('legacy tuple :', Number.isInteger(s), Number.isInteger(ns));
type : bigint
>= 49 ms : true
as milliseconds : true
Date.now delta : true
performance.now : true
legacy tuple : true true
Timers can fire slightly early or late (see Timers), which is why the assertions leave a millisecond of slack.
How a process ends: exit codes
A process ends in one of four ways: the event loop runs out of work (a natural exit), your code calls process.exit(), an error nobody
handles, or a signal. The result that other programs see is an exit status, a small integer: 0 means success, anything else means
failure, and shells, CI systems, npm run, docker run and orchestrators all key off that number. Node.js uses the following codes
itself.
| Code | Name | When it happens |
|---|---|---|
|
Success |
No more asynchronous work is pending and nothing set another code. |
|
Uncaught fatal exception |
An exception was not caught and no |
|
Unused |
Reserved by Bash for builtin misuse. |
|
Internal errors |
Failures inside Node.js bootstrapping or its own exception handler. Code 7 can also appear when an |
|
Fatal error |
An unrecoverable error in V8, with |
|
Unused |
Older releases used it for some uncaught exceptions. |
|
Invalid argument |
An unknown option, or an option that needs a value without one. Observed also for a missing file given to |
|
Invalid debug argument |
|
|
Unsettled top-level await |
|
|
Snapshot failure |
Node.js was building a V8 startup snapshot and the application state did not meet the requirements. |
greater than |
Signal exits |
Death by a fatal signal reports |
Your own programs should pick codes deliberately. 0 is success and 1 a generic failure; beyond that, the BSD sysexits.h convention
(codes 64 to 78, for example 64 for a usage error, 69 for an unavailable service, 70 for an internal software error) is widely understood by
command-line tools, though it is not part of Node.js, and anything above 125 should be left to shells and signals (126 and 127 are the shell’s "cannot
execute" and "not found"). Keep the set small, document it in your --help, and never exit with a value over 255: statuses are masked to
eight bits on POSIX.
import { spawnSync } from 'node:child_process';
import { execPath } from 'node:process';
const cases = [
['script ends normally', ['-e', '1']],
['uncaught exception', ['-e', 'throw new Error("boom")']],
['unhandled rejection', ['-e', 'Promise.reject(new Error("boom"))']],
['process.exitCode = 9', ['-e', 'process.exitCode = 9']],
['process.exit(42)', ['-e', 'process.exit(42)']],
['exitCode = 9, then exit(42)', ['-e', 'process.exitCode = 9; process.exit(42)']],
['exitCode = 9, then exit()', ['-e', 'process.exitCode = 9; process.exit()']],
['unsettled top-level await', ['--input-type=module', '-e', 'await new Promise(() => {})']],
['unsettled TLA, exitCode = 9', ['--input-type=module', '-e', 'process.exitCode = 9; await new Promise(() => {})']],
['unknown node option', ['--no-such-option', '-e', '1']],
['syntax error', ['-e', 'let let']],
['killed by SIGKILL', ['-e', 'process.kill(process.pid, "SIGKILL")']],
['SIGTERM, default handler', ['-e', 'process.kill(process.pid, "SIGTERM"); setTimeout(() => {}, 5000)']],
];
for (const [label, args] of cases) {
const { status, signal } = spawnSync(execPath, args, { stdio: 'ignore' });
console.log(label.padEnd(30), 'status =', String(status).padEnd(4), 'signal =', signal);
}
script ends normally status = 0 signal = null
uncaught exception status = 1 signal = null
unhandled rejection status = 1 signal = null
process.exitCode = 9 status = 9 signal = null
process.exit(42) status = 42 signal = null
exitCode = 9, then exit(42) status = 42 signal = null
exitCode = 9, then exit() status = 9 signal = null
unsettled top-level await status = 13 signal = null
unsettled TLA, exitCode = 9 status = 9 signal = null
unknown node option status = 9 signal = null
syntax error status = 1 signal = null
killed by SIGKILL status = null signal = SIGKILL
SIGTERM, default handler status = null signal = SIGTERM
Three lessons are in that table. An explicit code always beats an implicit one: process.exitCode = 9 turns the "unsettled top-level await"
status from 13 into 9, and process.exit(42) beats a previously assigned exitCode. process.exit() without an argument uses
process.exitCode when it was set. And a process killed by a signal has no exit status at all: the Node.js child_process API
reports status as null and signal as the signal name, while a POSIX shell translates it to 128 + signal number in $?. On the
macOS machine used here the shell reported 130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP and 137 for SIGKILL. The documentation says the default
SIGINT and SIGTERM handlers "exit with code 128 + signal number" after resetting the terminal mode; what an observer sees is a process terminated
by that signal, which is what the observed signal = SIGTERM above shows.
process.exitCode versus process.exit()
Both set the status, and they differ in one important way: process.exitCode = n records the status and lets the process finish its work,
while process.exit(n) ends the process right now, synchronously, after the 'exit' listeners run. Anything still queued is abandoned:
pending timers, in-flight network requests, a database connection waiting to be closed, and output that has been handed to a stream but not
yet flushed. The Node.js documentation warns specifically about the last one, because writes to process.stdout and process.stderr are
asynchronous for pipes on POSIX.
import { spawnSync } from 'node:child_process';
import { execPath } from 'node:process';
const writer = (ending) => `
process.stdout.write('x'.repeat(4 * 1024 * 1024)); // 4 MiB of output
${ending}
`;
for (const [label, ending] of [
['process.exit(1) ', 'process.exit(1);'],
['process.exitCode = 1 ', 'process.exitCode = 1;'],
]) {
const { stdout, status } = spawnSync(execPath, ['-e', writer(ending)], { maxBuffer: 16 * 1024 * 1024 });
console.log(label, 'status', status, '| bytes received:', stdout.length);
}
process.exit(1) status 1 | bytes received: 65536
process.exitCode = 1 status 1 | bytes received: 4194304
With process.exit(1) the reader got only the first 65536 bytes, the size of the operating system’s pipe buffer here; the rest was lost.
Setting process.exitCode delivered all of it. The exact byte count is platform and load dependent and is an observation, not a guarantee;
the loss is the documented behaviour. On Windows pipes are synchronous, so the same program would not lose data there in this way; that is how such bugs reach production: the code was tested on one platform
and run on another.
The rules that follow from this:
-
In ordinary code set
process.exitCodeand return. Make the program finish by running out of work, which also guarantees that streams are flushed and'exit'and'beforeExit'fire. -
If you must stop on an error, throw it. The documentation calls an uncaught error that terminates the process safer than
process.exit(). -
Call
process.exit()only at the very edge: in a CLI after the final output has been written and flushed, after you have closed what needs closing, or in a watchdog that fires when graceful shutdown has taken too long (see graceful shutdown). -
Never call
process.exit()in a library. It takes a decision that belongs to the application. Throw, reject or emit an error instead. -
Do not pass anything but an integer or an integer string. Since v20.0.0 other values throw
ERR_INVALID_ARG_TYPE(strings like'abc', objects) orERR_OUT_OF_RANGE(1.5), both of which were observed.
The 'beforeExit' and 'exit' events
'beforeExit' and 'exit' look similar and have opposite jobs.
'beforeExit' |
'exit' |
|
|---|---|---|
Fires when |
The event loop is empty and nothing more is scheduled. |
The process is about to end: after |
Not emitted for |
|
Death by a signal with its default action (observed: no event). |
Argument |
The current |
The exit code that will be used. |
What you may do |
Asynchronous work. Scheduling work keeps the process alive and the event fires again when the loop empties again. |
Synchronous work only. The loop is finished: timers scheduled here never run. Setting |
Typical use |
Flush buffered logs, send a final metric, drain a queue. |
Synchronous cleanup that must happen on every ending: remove a lock file, restore the terminal. |
import process from 'node:process';
import { writeSync } from 'node:fs';
let rounds = 0;
process.on('beforeExit', (code) => {
// Fires when the loop is empty. Scheduling work here keeps the process alive.
console.log(`beforeExit #${rounds + 1}, code ${code}`);
if (++rounds < 3) setTimeout(() => console.log(` ...extra work ${rounds}`), 10);
});
process.on('exit', (code) => {
// Synchronous only: the loop is finished, so this timer is abandoned.
setTimeout(() => console.log('never printed'), 0);
writeSync(1, `exit, code ${code}, exitCode property ${process.exitCode}\n`);
process.exitCode = 5; // the last word on the exit status
});
console.log('main script done');
main script done
beforeExit #1, code 0
...extra work 1
beforeExit #2, code 0
...extra work 2
beforeExit #3, code 0
exit, code 0, exitCode property undefined
status 5
The timer created inside the 'exit' listener never printed, and the status is the 5 assigned there. The three events came out in the order the diagram predicts, and
the 'beforeExit' listener ran three times because it scheduled work twice. A microtask queued inside an 'exit' listener was observed to run
before the process ended, but you should not build on that: the documented contract is "synchronous operations only".
import process from 'node:process';
process.on('beforeExit', () => console.log('beforeExit'));
process.on('exit', (code) => console.log('exit', code));
if (process.argv[2] === 'exit') process.exit(7); // exit only
if (process.argv[2] === 'throw') throw new Error('boom'); // exit only, code 1
console.log('end of script'); // beforeExit then exit
node exit-events2.js # end of script, then beforeExit, then exit 0
node exit-events2.js exit # exit 7 (no beforeExit)
node exit-events2.js throw # exit 1 (no beforeExit), after the error is printed
Because 'beforeExit' is not emitted when an explicit process.exit() or a crash ends the process, it is an opportunity, not a guarantee. For
cleanup that must run on every ending, use 'exit' for synchronous work, and for anything asynchronous do it before you decide to end:
in the signal handler or at the end of main(), as in graceful shutdown.
process.finalization: cleanup tied to an object’s lifetime
process.finalization.register(ref, callback) (Stability 1.1, Active development, added in v22.5.0) arranges for callback(ref, event) to run when the
process emits 'exit', but only if ref has not been garbage collected by then; registerBeforeExit() does the same for 'beforeExit', and
unregister(ref) cancels it. It exists so that a resource owner can promise "my temporary files are removed when the process ends"
without being kept alive forever by the registration. The documentation lists the rules: pass a plain function, not an arrow function that closes
over this (the closure would keep the object reachable), do the work synchronously, and do not rely on it for critical resources because the
callback is not guaranteed to run in every circumstance. The first call prints an ExperimentalWarning even on Node.js 26.
import { finalization } from 'node:process';
class TempWorkspace {
constructor(name) {
this.name = name;
// Pass a plain function and the object as `ref`; do not close over `this`.
finalization.register(this, (ws, event) => ws.dispose(event));
}
dispose(event) {
console.log(`cleaning up ${this.name} on ${event}`); // synchronous work only
}
}
const keep = new TempWorkspace('uploads'); // still referenced at exit
let dropped = new TempWorkspace('scratch');
finalization.unregister(dropped); // explicitly opt out
dropped = null;
console.log('main script done', keep.name);
ExperimentalWarning goes to stderr and was silenced with --no-warnings)main script done uploads
cleaning up uploads on exit
Only the workspace that was still referenced was cleaned up; the one that was explicitly unregistered was not. For application shutdown logic you write yourself, a
plain 'exit' listener plus the graceful-shutdown pattern below is simpler and portable.
Signals
A signal is a small message the operating system delivers to a process asynchronously: a user pressing Ctrl+C, a supervisor stopping a
service, another process calling kill. Each signal has a default action (usually "terminate"), and a program may replace the default with
its own handler. In Node.js a handler is simply an event listener on process whose name is the signal: process.on('SIGTERM', fn). The
handler receives the signal name as its first argument, so one function can serve several signals.
The facts that matter in practice, from the official documentation and from running the examples:
| Signal | Typical sender | Behaviour in Node.js |
|---|---|---|
|
Ctrl+C in a terminal. |
Supported on every platform. By default Node.js resets the terminal mode and ends the process (shell status 130). Installing a listener removes that default. Not generated when the terminal is in raw mode. |
|
|
Same default as |
|
The controlling terminal closed. |
Default terminates the process (shell status 129); a listener removes the default. On Windows it is generated when the console window is closed and Node.js is terminated unconditionally about 10 seconds later. |
|
|
Reserved by Node.js to start the inspector. A listener can be installed but may interfere with the debugger. See SIGUSR1. |
|
|
No default behaviour inside Node.js, so by default it terminates the process (observed shell status 159 on macOS, that is 128 plus 31). It is the default signal for |
|
Writing to a closed pipe or socket. |
Ignored by default; can have a listener. |
|
|
Cannot have a listener: |
|
Ctrl+Break on Windows. |
Delivered on Windows; can be listened on elsewhere but there is no way to send it. |
|
The terminal was resized. |
Delivered when the console size changes. On Windows only on a write that moves the cursor or in raw mode. |
|
The CPU or the kernel, for a crash. |
Not safe to handle in JavaScript when raised by a real fault: the process may hang. They are fine when raised artificially with |
Signal numbers are not portable (SIGUSR2 is 31 on the macOS machine used here); use names, or look the numbers up with
os.constants.signals. Two more facts are decisive when you design a handler. First, a signal listener does not keep the process alive:
signals are delivered asynchronously, and the documentation warns that Node.js may exit before the listener runs if the loop is otherwise
empty. Second, installing a listener removes the default action, so after process.on('SIGINT', …) Ctrl+C no longer stops the program
unless your handler does it. Forgetting that is how "Ctrl+C does nothing" and "the container will not stop" bugs are born.
import process, { kill } from 'node:process';
import { constants } from 'node:os';
console.log('SIGINT / SIGTERM / SIGUSR2 numbers:', constants.signals.SIGINT, constants.signals.SIGTERM, constants.signals.SIGUSR2);
// A listener replaces the default action (here: terminate) for that signal.
process.on('SIGUSR2', (signal) => {
console.log(`got ${signal}; reloading configuration instead of dying`);
});
// Send a signal to ourselves; process.kill() is really "send signal".
kill(process.pid, 'SIGUSR2');
// Signal 0 sends nothing; it only tests that a pid exists and is signalable.
const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
console.log('own pid alive :', alive(process.pid));
console.log('pid 2**22+1 alive:', alive(2 ** 22 + 1));
setTimeout(() => console.log('still running'), 50);
SIGINT / SIGTERM / SIGUSR2 numbers: 2 15 31
own pid alive : true
pid 2**22+1 alive: false
got SIGUSR2; reloading configuration instead of dying
still running
process.kill(pid, signal) is a signal sender, not a killer: it defaults to 'SIGTERM', accepts a name or a number, and the target decides what the signal does.
It throws if the pid does not exist, and signal 0 sends nothing, which makes it the portable existence test used above (the EPERM case
means "exists, but you may not signal it"). On Windows it throws when the pid refers to a process group.
// Is a signal listener alone enough to keep the process alive?
process.on('SIGUSR2', () => console.log('listener ran'));
process.kill(process.pid, 'SIGUSR2');
console.log('end of script');
node signals2.js; echo "exit=$?"
# end of script
# exit=0 <- the listener never ran: the loop was empty, so the process ended first
That output was identical on Node.js 26, 24 and 22. A long-running server stays alive because of its open listening socket, not because of its signal handlers; if your program is a worker with nothing else pending, it must keep something referenced, as explained in The Event Loop.
What the default actions look like from the shell
The following script starts an idle Node.js process, sends it each signal in turn and prints the status the shell sees.
#!/bin/bash
for sig in INT TERM HUP USR2 KILL; do
node idle.js >/dev/null 2>&1 &
PID=$!
sleep 0.7
kill -$sig $PID
wait $PID 2>/dev/null
echo "SIG$sig -> shell status $?"
done
node -e "try { process.on('SIGKILL', () => {}) } catch (e) { console.log(e.code, e.message) }"
SIGINT -> shell status 130
SIGTERM -> shell status 143
SIGHUP -> shell status 129
SIGUSR2 -> shell status 159
SIGKILL -> shell status 137
EINVAL uv_signal_start EINVAL
SIGUSR1: the inspector’s signal
SIGUSR1 is reserved by Node.js: on a POSIX system, sending it to a running process starts the inspector and prints the address a debugger
can connect to, without having restarted the process with --inspect. That is a lifesaver for a service already misbehaving in production, and a
security concern in the wrong environment, because anyone who can signal the process (the same user, or root) can open a debugging port that
allows arbitrary code execution. Control it deliberately:
-
--disable-sigusr1(stable since v24.8.0 and v22.20.0) turns the feature off. -
The permission model (
--permission) does not create the listener at all. -
Bind the inspector to localhost (the default is
127.0.0.1:9229) and tunnel to it; never expose it. -
Do not install your own
SIGUSR1handler: you may break the debugger. UseSIGUSR2for your own purposes.
#!/bin/bash
# $1: extra node flags
node $1 idle.js > out.txt 2> err.txt &
PID=$!
sleep 1
kill -USR1 $PID
sleep 1
if kill -0 $PID 2>/dev/null; then echo "alive after SIGUSR1"; else wait $PID; echo "terminated, shell status $?"; fi
sed -E 's#[0-9a-f]{8}-[0-9a-f-]{27}#<uuid>#' err.txt | head -4
kill $PID 2>/dev/null; wait $PID 2>/dev/null
rm -f out.txt err.txt
alive after SIGUSR1
Debugger listening on ws://127.0.0.1:9229/<uuid>
For help, see: https://nodejs.org/learn/getting-started/debugging
With --disable-sigusr1 and with --permission the process stayed alive and printed no debugger line: the signal was ignored (observed on all three lines).
The full debugging workflow, including Chrome DevTools and VS Code, is the subject of Debugging.
SIGUSR2: a signal for you, and for diagnostic reports
SIGUSR2 is the signal Node.js leaves to the application, with one exception: --report-on-signal makes the process write a diagnostic report (a JSON file
with JavaScript and native stack traces, heap statistics, libuv handles and resource usage) when it receives the signal named by --report-signal,
which defaults to SIGUSR2 and is not supported on Windows. This is an inexpensive way to capture the state of a stuck process without stopping it.
The report features are covered in Diagnostic Reports, Logging & Observability.
#!/bin/bash
rm -rf reports; mkdir reports
node --report-on-signal --report-dir=reports idle.js > /dev/null &
PID=$!
sleep 1
kill -USR2 $PID
sleep 1
echo "reports written: $(ls reports | wc -l | tr -d ' ')"
ls reports | sed -E 's/[0-9]{8}\.[0-9]{6}\.[0-9]+\.[0-9]+\.[0-9]+/<timestamp>.<pid>/'
node -e "const f=require('fs').readdirSync('reports')[0]; const r=JSON.parse(require('fs').readFileSync('reports/'+f)); console.log(r.header.trigger, r.header.event, Object.keys(r).slice(0,5).join(','))"
kill $PID; wait $PID 2>/dev/null; rm -rf reports
Writing Node.js report to file: report.20261003.235502.44995.0.001.json
Node.js report completed
reports written: 1
report.<timestamp>.<pid>.json
Signal SIGUSR2 header,javascriptStack,javascriptHeap,nativeStack,resourceUsage
Windows: what exists and what does not
Windows has no POSIX signals, so Node.js emulates a few and the rest do not exist. Code that must run on Windows should treat signals as an optimisation, not as the only way to stop a program.
| Item | Behaviour |
|---|---|
|
Supported from the terminal (Ctrl+C, unless the console is in raw mode). |
|
Delivered when Ctrl+Break is pressed. |
|
Generated when the console window is closed; Windows ends the process unconditionally about 10 seconds later, so cleanup must be quick. |
|
Not supported, although a listener can be installed. |
|
Delivered on a console resize, but only on a write that moves the cursor or in raw mode with a readable TTY. |
|
Emulated: the target is terminated unconditionally (no handler runs) and the parent sees it as killed by signal. Signal |
|
Throws on Windows. |
|
POSIX signals; the report signal is documented as not supported on Windows. Use |
On Windows the dependable ways to ask a process to shut down gracefully are an application-level message (IPC, an HTTP admin endpoint or the stdin of a child), not a signal.
Containers and PID 1
A container’s main process is PID 1, and Linux gives that process special treatment: the only signals that can be sent to PID 1 are those for which it has explicitly installed
a handler (see kill(2)). A Node.js program started directly as PID 1 without a SIGTERM listener will therefore ignore docker stop until the runtime gives up and sends SIGKILL
after the grace period (10 seconds for Linux containers by default in Docker, 30 seconds for a Kubernetes pod unless terminationGracePeriodSeconds says otherwise). The cure
is the pattern in the next section. The experiment below was run with the official node:24 image (Node.js 24.21.0): each container was sent SIGTERM with docker kill --signal=SIGTERM,
and its state was read two seconds later.
docker run -d --name pid1 node:24 node -e "setInterval(() => {}, 1000)" # no handler
docker run -d --name pid1 --init node:24 node -e "setInterval(() => {}, 1000)" # init process as PID 1
docker run -d --name pid1 node:24 node -e "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"
docker kill --signal=SIGTERM pid1; sleep 2; docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' pid1
running exit=0 <- no handler: SIGTERM ignored, the container keeps running
exited exit=143 <- --init: the init process forwarded the signal; Node.js died of SIGTERM (128 + 15)
exited exit=0 <- own handler: clean exit with the status the program chose
The same experiment on Linux without containers behaves like the macOS runs above: a process that is not PID 1 dies of SIGTERM by default.
Launch Node.js in exec form (CMD ["node", "server.js"], not a shell string and not npm start, which put another process between the runtime and
your program whose signal forwarding you would have to verify) or use docker run --init, which runs a small init process that forwards signals and reaps children.
Dockerfile details are in Dockerfile Fundamentals and the troubleshooting row for a container that ignores SIGTERM is in
Debugging Containers; the Node.js side of a production deployment is
Production Deployment.
Graceful shutdown
A graceful shutdown answers a signal by finishing what it started and refusing new work: stop accepting connections, let requests in flight complete, close the database pool and the queue consumers, flush logs, then end with a meaningful status. Every supervisor sends a signal first and a hard kill later, so the program has a budget measured in seconds. The pattern has four parts, and the example below implements them in about forty lines for the Bookshelf server:
-
Register one handler per signal (
SIGINTfor the developer’s Ctrl+C,SIGTERMfor the platform) and make it idempotent: a second signal while shutting down must not start the work again. -
Run the cleanup asynchronously and set
process.exitCode, then let the loop drain. Do not callprocess.exit()from the happy path. -
Add a watchdog timer that calls
process.exit(1)if cleanup takes longer than the grace period.unref()it so that it never keeps a finished process alive. -
Close the HTTP server in two steps:
server.close()stops accepting connections and, since v19.0.0, also closes idle keep-alive connections;server.closeAllConnections()after a short deadline cuts those that are still busy (an upload, a stream, a stalled client).
import process from 'node:process';
import { once } from 'node:events';
/**
* Run `cleanup` once, on the first of the given signals, then let the process
* exit by itself with the conventional 128 + signal number status.
*/
export function onShutdown(cleanup, { signals = ['SIGINT', 'SIGTERM'], graceMs = 10_000 } = {}) {
let started = false;
const handler = async (signal) => {
if (started) return; // ignore repeated signals
started = true;
console.log(`${signal} received, shutting down`);
const watchdog = setTimeout(() => { // last resort if cleanup hangs
console.error('graceful shutdown timed out');
process.exit(1);
}, graceMs);
watchdog.unref(); // never keeps the process alive by itself
try {
await cleanup(signal);
process.exitCode = 128 + signalNumber(signal);
} catch (err) {
console.error('cleanup failed:', err.message);
process.exitCode = 1;
}
};
for (const signal of signals) process.on(signal, handler);
return () => { for (const signal of signals) process.off(signal, handler); };
}
function signalNumber(name) {
return { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 }[name] ?? 0;
}
/** Stop accepting connections, close idle ones, force-close the rest after a deadline. */
export async function closeServer(server, forceAfterMs = 2_000) {
const closed = once(server, 'close');
server.close(); // stop accepting; closes idle keep-alive sockets
const timer = setTimeout(() => server.closeAllConnections(), forceAfterMs);
timer.unref();
await closed;
clearTimeout(timer);
}
Source: Node.js API: Process (signal events), Node.js API: Process (process.exitCode), Node.js API: Timers (timeout.unref), Node.js API: HTTP (server.closeAllConnections)
import http from 'node:http';
import { once } from 'node:events';
import { onShutdown, closeServer } from './shutdown.js';
const books = [];
const server = http.createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/books') {
let body = '';
for await (const chunk of req) body += chunk; // waits until the client finishes the body
books.push(JSON.parse(body));
res.writeHead(201, { 'content-type': 'application/json' }).end(body);
return;
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(books));
} catch (err) {
// A request cut off by closeAllConnections() must not crash the process.
console.log('request aborted:', err.code ?? err.message);
}
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
console.log('listening', server.address().port);
onShutdown(async () => {
await closeServer(server, 500); // a busy connection would otherwise block close() forever
console.log('server closed');
});
The driver below starts the server as a child process, opens a request that never completes (a POST whose body is never finished, so the connection stays busy and a plain close()
would wait forever), sends SIGTERM, and reports how the child ended.
import { spawn } from 'node:child_process';
import { execPath } from 'node:process';
import { once } from 'node:events';
import http from 'node:http';
const child = spawn(execPath, ['serve.js'], { stdio: ['ignore', 'pipe', 'inherit'] });
child.stdout.setEncoding('utf8');
const { promise: portReady, resolve } = Promise.withResolvers();
child.stdout.on('data', (chunk) => {
process.stdout.write(` child: ${chunk}`);
const match = chunk.match(/listening (\d+)/);
if (match) resolve(Number(match[1]));
});
const port = await portReady;
// A request whose body never finishes keeps its connection busy, so close() alone would wait forever.
const stalled = http.request({ host: '127.0.0.1', port, path: '/books', method: 'POST' });
stalled.on('error', (err) => console.log('stalled client sees:', err.code));
stalled.write('{"title": "Unfinished');
await once(stalled, 'socket');
console.log('request in flight, sending SIGTERM');
child.kill('SIGTERM');
const [code, signal] = await once(child, 'exit');
console.log('child exit code =', code, '| signal =', signal);
server closed and stalled client sees lines can swap places) child: listening 54237
request in flight, sending SIGTERM
child: SIGTERM received, shutting down
server closed
stalled client sees: ECONNRESET
child exit code = 143 | signal = null
Source: Node.js API: Child process (child_process.spawn), Node.js API: Child process (subprocess.kill), Node.js API: HTTP (http.request)
On Node.js 24 and 22 the same run prints one more child line after server closed: request aborted: ECONNRESET. There, the request stream of the connection cut by
closeAllConnections() rejects the for await loop with an aborted error, and without the try/catch in serve.js that rejection was observed to crash the child with
exit code 1 instead of 143. Any request handler that reads a body asynchronously must therefore expect to be interrupted during shutdown.
The exit status 143 is 128 + 15: the child reports that it ended because of SIGTERM by convention while still having run its cleanup, so the orchestrator’s logs are unambiguous.
With more resources (a database pool, a queue consumer) the cleanup callback closes them in the reverse order of creation after the server has stopped accepting work. The same
pattern in a framework is described in NestJS lifecycle events and shutdown, where enableShutdownHooks() installs the signal listeners for you.
Last-resort handlers: uncaughtException and unhandledRejection
The details of error handling belong to Errors & Error Handling; the process-level hooks matter here because they decide the exit status.
-
'uncaughtException'fires when a JavaScript exception reaches the event loop uncaught. Without a listener Node.js prints the stack and exits with code 1, overriding anyexitCode. With a listener the default is replaced, and the process continues and exits with 0 unless you setprocess.exitCode. The documentation calls the event "a crude mechanism … intended to be used only as a last resort"; the application is in an undefined state, so the correct use is synchronous cleanup followed by a controlled exit, and an external supervisor restarts the service. -
'uncaughtExceptionMonitor'is called first, with the same arguments, and does not change the outcome: use it for logging and metrics. Theoriginargument says whether the error came from a synchronous throw ('uncaughtException') or from a rejection ('unhandledRejection'). -
'unhandledRejection'fires when a promise is rejected and no handler is attached within a turn of the loop. Since v15.0.0 the default mode isthrow: if no listener handles the event, the rejection is raised as an uncaught exception.--unhandled-rejections=<mode>changes it. -
process.setUncaughtExceptionCaptureCallback()is the lower-level hook that tools use (andprocess.addUncaughtExceptionCaptureCallback(), Experimental since v25.9.0, allows several).
| Mode | Behaviour |
|---|---|
|
Emit |
|
Raise the rejection as an uncaught exception; if that exception is handled, |
|
Always print a warning, whether or not a listener exists; no exit. |
|
Emit |
|
Silence all warnings. |
A rejection that happens while the command-line entry point’s ES module is statically loading is always raised as an uncaught exception.
import process from 'node:process';
import { writeSync } from 'node:fs';
const mode = process.argv[2];
// Observe without changing behaviour: the process still crashes with code 1.
process.on('uncaughtExceptionMonitor', (err, origin) => {
writeSync(2, `[monitor] ${origin}: ${err.message}\n`);
});
if (mode === 'handler' || mode === 'handler-sets-code') {
process.on('uncaughtException', (err, origin) => {
writeSync(2, `[handler] ${origin}: ${err.message}\n`); // synchronous logging only
if (mode === 'handler-sets-code') process.exitCode = 70; // otherwise the exit status becomes 0
});
}
if (mode === 'rejection-handler') {
process.on('unhandledRejection', (reason) => {
writeSync(2, `[rejection] ${reason.message}\n`);
throw reason; // turn it into an uncaught exception: crash loudly
});
}
setTimeout(() => writeSync(2, 'timer still ran\n'), 20);
if (mode === 'rejection' || mode === 'rejection-handler') Promise.reject(new Error('lost promise'));
else throw new Error('sync failure');
import { spawnSync } from 'node:child_process';
import { execPath } from 'node:process';
const run = (flags, mode) => {
const { status, stderr } = spawnSync(execPath, [...flags, 'last-resort.js', mode], { encoding: 'utf8' });
const seen = stderr.split('\n').filter((l) => /^\[|timer/.test(l)).map((l) => l.split(':')[0]);
const warned = stderr.includes('UnhandledPromiseRejectionWarning');
console.log(`${[...flags, mode].join(' ').padEnd(50)} status ${status} ${seen.join(' ')}${warned ? ' (warning printed)' : ''}`);
};
run([], 'plain');
run([], 'handler');
run([], 'handler-sets-code');
run([], 'rejection');
run([], 'rejection-handler');
run(['--unhandled-rejections=warn'], 'rejection');
run(['--unhandled-rejections=warn-with-error-code'], 'rejection');
run(['--unhandled-rejections=strict'], 'rejection');
plain status 1 [monitor] unhandledRejection
handler status 0 [monitor] unhandledRejection [handler] unhandledRejection timer still ran
handler-sets-code status 70 [monitor] unhandledRejection [handler] unhandledRejection timer still ran
rejection status 1 [monitor] unhandledRejection
rejection-handler status 1 [rejection] lost promise [monitor] uncaughtException
--unhandled-rejections=warn rejection status 0 timer still ran (warning printed)
--unhandled-rejections=warn-with-error-code rejection status 1 timer still ran (warning printed)
--unhandled-rejections=strict rejection status 1 [monitor] unhandledRejection
The handler row is the trap: the program swallowed a crash and reported success (status 0). The handler-sets-code row shows the fix. The plain rows show a quirk of ES modules worth
remembering: a synchronous throw at the top level of an ES module is reported with the origin 'unhandledRejection', because module evaluation is asynchronous. The rejection-handler row shows
the recommended pattern for 'unhandledRejection': log it, then throw it so that it becomes an uncaught exception with the default fatal behaviour.
process.getBuiltinModule()
process.getBuiltinModule(id) returns a built-in module synchronously and without an import statement, or undefined when id is not a built-in. It was added in v22.3.0 and v20.16.0
for code that must run in several environments: an ES module that does import fs from 'node:fs' fails to load in a browser or another runtime, a dynamic import() makes the module
asynchronous, and require does not exist in ES modules. With getBuiltinModule the same file can ask "am I in Node.js?" and load what it needs in one synchronous step. It accepts the ids that
module.isBuiltin(id) recognises, with or without the node: prefix, but modules that exist only under the prefix (node:test, node:sqlite, node:sea) must be requested with it. And it
always returns the real built-in even if code has tampered with require.cache for the bare id, which makes it a small defence against monkey-patching.
import { createRequire } from 'node:module';
// 1. Synchronous access to built-ins from an ES module, no import needed
const fs = process.getBuiltinModule('node:fs');
console.log('node:fs readFileSync :', typeof fs.readFileSync);
console.log('bare id also works :', process.getBuiltinModule('fs') === fs);
console.log('unknown id :', process.getBuiltinModule('left-pad'));
console.log('"test" needs a prefix :', process.getBuiltinModule('test'), typeof process.getBuiltinModule('node:test'));
// 2. Portable code: works in Node.js and in runtimes without a `process` global
const isNode = typeof globalThis.process?.getBuiltinModule === 'function';
const { hostname } = isNode ? globalThis.process.getBuiltinModule('node:os') : { hostname: () => 'unknown' };
console.log('portable hostname call:', typeof hostname());
// 3. It returns the real built-in even if code tampers with require.cache
const require = createRequire(import.meta.url);
require.cache[require.resolve('path')] = { exports: { sep: 'patched' } };
console.log('require("path").sep :', require('path').sep);
console.log('getBuiltinModule sep :', process.getBuiltinModule('path').sep);
node:fs readFileSync : function
bare id also works : true
unknown id : undefined
"test" needs a prefix : undefined function
portable hostname call: string
require("path").sep : patched
getBuiltinModule sep : /
process.features
process.features is an object of booleans (and one string) that describe how this Node.js binary was built, so that tools can adapt to the runtime they find instead of parsing version
numbers. The useful entries are inspector (the inspector is compiled in), require_module (require() of ES modules is supported), typescript (type stripping: 'strip' by default, false with
--no-strip-types), tls (TLS support), cached_builtins (built-in modules are cached in the binary) and debug (a debug build). Do not check ipv6, uv, tls_alpn, tls_ocsp or tls_sni: they were
deprecated in v23.4.0 and v22.13.0 because they are always true (ipv6, uv) or equal to tls in every current build. In a build with a minimal feature set a property can be false, which is the point
of the object: embedders and single-executable builds differ.
import { features } from 'node:process';
// Only the useful, non-deprecated entries
console.log({
inspector: features.inspector,
require_module: features.require_module, // require(esm) support
typescript: features.typescript, // 'strip' unless --no-strip-types
tls: features.tls,
cached_builtins: features.cached_builtins,
debug: features.debug,
});
{
inspector: true,
require_module: true,
typescript: 'strip',
tls: true,
cached_builtins: true,
debug: false
}
Started with --no-strip-types (Node.js 26 and 24; on 22 the flag is --no-experimental-strip-types) the typescript entry is false; started with --no-experimental-require-module the require_module entry
is false. Prefer testing a feature over testing a version: if (process.features.require_module) documents why the code branches.
Other members worth knowing
Warnings: process.emitWarning() and the 'warning' event
Node.js reports process warnings (deprecations, experimental features, listener leaks) on stderr and as 'warning' events. process.emitWarning() lets libraries use the same channel, with an
optional type and code; --no-warnings silences the output but the event is still emitted; --no-deprecation suppresses DeprecationWarning entirely.
import process from 'node:process';
process.on('warning', (w) => console.log(`event: ${w.name} [${w.code}] ${w.message}`));
process.emitWarning('Using the legacy shelf API', { type: 'DeprecationWarning', code: 'BOOKSHELF_DEP001', detail: 'Use /books instead.' });
process.emitWarning('Cache is 90% full', 'CapacityWarning');
(node:52547) [BOOKSHELF_DEP001] DeprecationWarning: Using the legacy shelf API
Use /books instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
event: DeprecationWarning [BOOKSHELF_DEP001] Using the legacy shelf API
(node:52547) CapacityWarning: Cache is 90% full
event: CapacityWarning [undefined] Cache is 90% full
Replacing the process image: process.execve()
process.execve(file, args, env) (Stability 1 Experimental, POSIX only) replaces the current process with a new program through the execve(2) system call: same pid, same standard streams, nothing else.
On success it never returns and no JavaScript cleanup runs, not even 'exit' listeners. If the call fails it throws an error whose code is the errno string (for example 'ENOENT') and the process continues.
Do your cleanup first. It is a niche tool for launchers and wrappers; for a normal child process use child_process instead.
import { execve, env, pid, execPath, platform } from 'node:process';
if (platform === 'win32') throw new Error('process.execve() is not available on Windows');
process.on('exit', () => console.log('exit handlers do NOT run on a successful execve'));
if (env.STAGE !== 'second') {
console.log('first image, pid', pid);
// Replaces this process image in place: same pid, no cleanup, no return on success.
execve(execPath, [execPath, import.meta.filename], { ...env, STAGE: 'second' });
}
console.log('second image, STAGE =', env.STAGE, '| same pid as before:', pid > 0);
ExperimentalWarning was silenced with --no-warnings)first image, pid 52545
second image, STAGE = second | same pid as before: true
exit handlers do NOT run on a successful execve
(The last line is printed by the second image’s own 'exit' listener when it ends; the first image’s listener never ran.)
Asking what the permission model allows: process.permission
Only when the process was started with --permission (or --permission-audit) does process.permission exist; otherwise it is undefined. Because it is undefined, a named import such
as import { permission } from 'node:process' fails with a SyntaxError ("does not provide an export named"); read it from the default export or the global instead.
import process from 'node:process';
// process.permission only exists when the permission model is on (--permission)
if (!process.permission) {
console.log('permission model is off: process.permission is', process.permission);
} else {
console.log('fs.read :', process.permission.has('fs.read'));
console.log('fs.write :', process.permission.has('fs.write'));
console.log('child :', process.permission.has('child'));
}
node --permission --allow-fs-read='*'permission model is off: process.permission is undefined
fs.read : true
fs.write : false
child : false
The model itself, and --allow-net (Stability 1.1), are in The Permission Model.
Pitfalls
| Mistake | What to do instead |
|---|---|
|
Set |
An |
Log synchronously, set a non-zero |
A |
Installing a listener removes the default. Always finish by letting the loop empty (with |
Relying on a signal listener to keep the program alive |
Keep a server, timer or open handle referenced; signals do not keep the loop alive. |
Handling |
Impossible. Design for crash safety (idempotent work, transactions) and use |
Starting Node.js as PID 1 in a container without handlers, or through |
Use exec-form |
Treating |
Values are strings; |
Writing |
Use |
`process.env.SECRET |
|
"fallback"` for a key or token |
Fail fast when a secret is missing; a hard-coded fallback ships to production unnoticed. |
Calling |
Anchor paths with |
Measuring durations with |
Use |
Reading |
Watch |
Relying on |
It does not fire for |
Calling |
Throw or reject; the application decides when to end. |
Installing a |
The inspector uses it. Use |
What changed recently
| Version | Change | Effect on you |
|---|---|---|
Node.js 20.0.0 |
|
Wrong types throw instead of being coerced. |
Node.js 15.0.0 |
The default of |
An unhandled rejection crashes the process; older tutorials that describe a warning are out of date. |
Node.js 20.16.0 and 22.3.0 |
|
Synchronous, import-free access to built-ins in portable code. |
Node.js 22.5.0 |
|
Object-lifetime cleanup at exit. |
Node.js 22.10.0 and 23.0.0 |
|
Tools can detect type stripping and |
Node.js 22.13.0 and 23.4.0 |
|
Stop checking them. |
Node.js 22.14.0 and 23.7.0 |
|
You can switch off signal-triggered inspector start. |
Node.js 22.15.0 and 23.11.0 |
|
Replace the process image from JavaScript on POSIX. |
Node.js 22.16.0 and 24.0.0 |
|
Safe to use for container-aware limits. |
Node.js 22.21.0 and 24.10.0 |
|
|
Node.js 24.12.0 and 25.2.0 |
Type stripping is stable; |
Check the value rather than a version. |
Node.js 25.9.0 |
|
Several capture callbacks can coexist. |
Node.js 26.0.0 |
The |
Only |
Node.js 22.23.3, 24.21.0, 26.10.0 |
Current patch releases of the three lines. |
Run |
Where the books are outdated or incomplete
Efficient Node.js (Samer Buna, O’Reilly 2025) introduces process.argv and process.env early and explains correctly that process.env is a copy of the environment that cannot change the
operating-system variables of the parent. Where it has aged is in the tools around it: it shows loading a .env file through the third-party dotenv package, with -r dotenv/config,
while the runtime now has --env-file, --env-file-if-exists, process.loadEnvFile() and util.parseEnv(), all stable since v24.10.0 and v22.21.0. Its coverage of unhandled errors recommends an
'uncaughtException' listener that exits with code 1 after cleanup, which is the right spirit, but it does not mention 'uncaughtExceptionMonitor', the origin argument, the
--unhandled-rejections modes, the ES-module quirk shown above, or the trap that a listener without an exit code turns a crash into success. It does not cover exit codes in depth, signals,
graceful shutdown, PID 1 in containers, process.getBuiltinModule() or process.features, which are the subject of this page.
Node.js Projects (Jonathan Wexler, O’Reilly 2025) scatters process.exit(1) through error branches of its servers and scripts. That is easy to read and wrong for programs whose output is consumed
by a pipe, as the truncation example shows; process.exitCode plus a natural exit is the safe form. It reads ports and keys with the process.env.X || default idiom, which is risky for numbers and
dangerous for secrets: || replaces valid falsy values, and a hard-coded fallback for a JWT secret means a missing variable silently deploys a known key. The safe form fails at startup when a required
secret is absent and uses ?? only for harmless defaults. It also parses command-line arguments by slicing process.argv, where the built-in util.parseArgs() validates options and types, and it uses
dotenv where --env-file is now enough. Neither book teaches signal handling, so the "Ctrl+C does nothing" and "the container will not stop" failures of this page are not covered.
| Statement you may meet | Current situation |
|---|---|
"Use |
|
"Call |
Set |
"`process.exit('abc')` or |
Only integers and integer strings are accepted since v20.0.0. |
"An unhandled rejection only prints a warning" |
The default is |
"`process.hrtime()` returns the time" |
Use |
"`process.mainModule` is the entry module" |
Deprecated since v14.0.0; use |
"SIGTERM works on Windows" |
It does not; use |
"`process.env.DEBUG = false` disables debugging" |
It stores the truthy string |
References
-
Node.js API: Process, https://nodejs.org/docs/latest/api/process.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Deprecated APIs, https://nodejs.org/docs/latest/api/deprecations.html
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: Child processes, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: Stream, https://nodejs.org/docs/latest/api/stream.html
-
Node.js API: OS, https://nodejs.org/docs/latest/api/os.html
-
Node.js API: Util, https://nodejs.org/docs/latest/api/util.html
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html
-
Node.js API: Diagnostic report, https://nodejs.org/docs/latest/api/report.html
-
Node.js API: Performance measurement APIs, https://nodejs.org/docs/latest/api/perf_hooks.html
-
Node.js API: Modules: node:module API, https://nodejs.org/docs/latest/api/module.html
-
Node.js API: Modules: TypeScript, https://nodejs.org/docs/latest/api/typescript.html
-
Node.js API: Modules: ECMAScript modules, https://nodejs.org/docs/latest/api/esm.html
-
Node.js API: Timers, https://nodejs.org/docs/latest/api/timers.html
-
Node.js API: TTY, https://nodejs.org/docs/latest/api/tty.html
-
Node.js Learn: How to read environment variables from Node.js, https://nodejs.org/learn/command-line/how-to-read-environment-variables-from-nodejs
-
Node.js Learn: Debugging Node.js, https://nodejs.org/learn/getting-started/debugging
-
Linux man-pages: signal(7), https://man7.org/linux/man-pages/man7/signal.7.html
-
Linux man-pages: kill(2), https://man7.org/linux/man-pages/man2/kill.2.html
-
Docker: docker container stop, https://docs.docker.com/reference/cli/docker/container/stop/
-
Docker: docker container run, https://docs.docker.com/reference/cli/docker/container/run/
-
Kubernetes: Pod lifecycle, https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/
-
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/