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

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

process.argv, argv0, execArgv, execPath, cwd(), chdir(), pid, ppid, platform, arch, uptime()

Stable

Present in every supported line.

process.env

Stable

Implicit conversion of non-string values is a documentation-only deprecation (DEP0104) since v10.0.0, with --pending-deprecation support.

process.loadEnvFile(), --env-file, --env-file-if-exists, util.parseEnv()

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).

process.memoryUsage(), memoryUsage.rss(), cpuUsage(), resourceUsage()

Stable

arrayBuffers added to memoryUsage() in v13.9.0 and v12.17.0; memoryUsage.rss() since v15.6.0.

process.availableMemory(), process.constrainedMemory()

Stable

Stable since v24.0.0 and v22.16.0 (experimental before); constrainedMemory() return value aligned with libuv in v22.0.0.

process.hrtime.bigint()

Stable

Added in v10.7.0. The older process.hrtime() tuple form is 3 Legacy.

process.exit(), process.exitCode

Stable

Since v20.0.0 only integers or integer strings are accepted (DEP0164, End-of-Life in v20.0.0).

'beforeExit', 'exit', 'uncaughtException', 'uncaughtExceptionMonitor', 'unhandledRejection'

Stable

'uncaughtExceptionMonitor' since v13.7.0 and v12.17.0; the default for --unhandled-rejections has been throw since v15.0.0.

Signal events ('SIGINT', 'SIGTERM', …​), process.kill()

Stable

Not available in worker threads. Windows supports only a small subset.

--disable-sigusr1

Stable

Added in v23.7.0 and v22.14.0; no longer experimental in v24.8.0 and v22.20.0.

--report-on-signal, --report-signal

Stable

No longer experimental in v13.12.0 and v12.17.0; default signal SIGUSR2, not supported on Windows.

process.getBuiltinModule()

Stable

Added in v22.3.0 and v20.16.0.

process.features.inspector, .require_module, .tls, .cached_builtins, .debug

Stable

.require_module added in v23.0.0, v22.10.0 and v20.19.0.

process.features.typescript

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 transform value was removed in v26.0.0.

process.features.ipv6, .tls_alpn, .tls_ocsp, .tls_sni, .uv

0 Deprecated

Deprecated in v23.4.0 and v22.13.0: ipv6 and uv are always true; the three TLS flags mirror process.features.tls.

process.finalization.register(), registerBeforeExit(), unregister()

1.1 Active development

Added in v22.5.0.

process.execve()

1 Experimental

Added in v23.11.0 and v22.15.0; since v26.1.0 a failing execve(2) throws instead of aborting the process. Not available on Windows or IBM i.

process.addUncaughtExceptionCaptureCallback()

1 Experimental

Added in v25.9.0.

process.permission

Follows the permission model (2 Stable)

Added in v20.0.0; process.permission.drop() is 1.1 Active development. Only defined when the permission model is on.

process.mainModule, process.umask() without argument

0 Deprecated

DEP0138 (use require.main) and DEP0139 (documentation-only, a race between threads).

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.

flowchart TD A["Start: parse flags, load the entry point"] --> B["Run the main script"] B --> C{"Loop still alive?"} C -- yes --> D["Run timers, I/O callbacks, signal handlers"] D --> C C -- no --> E["'beforeExit' event: may schedule more work"] E --> F{"New work scheduled?"} F -- yes --> D F -- no --> G["'exit' event: synchronous code only"] G --> H["Process ends with the exit code"] B -. "process.exit() or uncaught exception" .-> G D -. "process.exit() or uncaught exception" .-> G D -. "signal with the default action" .-> K["Killed by the signal: no events"]
Figure 1. A Node.js process from start to exit, with the events that fire on the way

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.

Reading the identity of the current process
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)');
Output on Node.js 26 (the line with 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.

Table 2. Identity members at a glance
Member Meaning Watch out for

process.pid, process.ppid

Process id and the id of its parent. Useful in logs and for kill(pid, signal).

The pid belongs to the process, not to your container; inside a container it is often 1 (see containers).

process.platform

The platform Node.js was compiled for: 'aix', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32', and 'android' where supported.

It is 'win32' on 64-bit Windows too. Use process.arch for the CPU ('arm64', 'x64', …​).

process.title

The title shown by ps, writable.

Platforms limit the length; on Linux and macOS it is bounded by the binary name plus the command-line arguments.

process.uptime()

Seconds since the process started, with fractions.

It measures the process, not the application: a worker or a restarted container resets it.

process.getuid(), process.geteuid(), process.getgid()

Numeric identities.

POSIX only: they are undefined on Windows, so guard the call with if (process.getuid).

process.stdin, process.stdout, process.stderr

The three standard streams, each with an isTTY flag and a fixed fd.

Writes are synchronous for files, asynchronous for pipes on POSIX and for terminals on Windows, which is the root of the process.exit() truncation below.

Deciding behaviour from where the standard streams point
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],
}));
Output when stdin, stdout and stderr are all redirected (a terminal prints 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.

cwd is process-wide state, the module directory is not
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);
Output (on Node.js 26, 24 and 22)
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, null or undefined stores its string form, so env.DEBUG_SQL = false stores the truthy string "false" and env.X = undefined stores 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 is delete env.NAME.

  • Changes stay in the process and its children. They are inherited by child processes started afterwards (unless you pass your own env option) but never reach the parent shell: node -e 'process.env.foo = "bar"' && echo $foo prints an empty line.

  • Names are case-insensitive on Windows only; on every other platform Path and PATH are different variables.

  • Worker threads get a copy of the parent’s process.env by default (since v11.14.0), configurable with the env option of Worker; only the main thread can change the real environment.

The rules of process.env, one by one
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());
Output on Node.js 26, 24 and 22
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.

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.

Loading and parsing an env file
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);
Output on Node.js 26, 24 and 22, followed by the flag forms
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

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:

Table 3. The fields of process.memoryUsage()
Field Meaning

rss

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.

heapTotal, heapUsed

V8’s allocated and used heap (JavaScript objects). In a worker thread they refer to that thread only.

external

Memory of C++ objects tied to JavaScript objects managed by V8.

arrayBuffers

Memory for ArrayBuffer and SharedArrayBuffer, including every Node.js Buffer. It is also included in external. It may be 0 when Node.js is embedded.

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.

What a Buffer and a million small objects do to the numbers
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
Output on Node.js 26 (the second line varies by a few megabytes per run and per line)
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.

Measuring an interval with a monotonic clock
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));
Output on Node.js 26, 24 and 22
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.

Table 4. Exit codes used by Node.js (from the Node.js process documentation)
Code Name When it happens

0

Success

No more asynchronous work is pending and nothing set another code.

1

Uncaught fatal exception

An exception was not caught and no 'uncaughtException' handler dealt with it. Observed for an unhandled rejection and for a syntax error as well.

2

Unused

Reserved by Bash for builtin misuse.

3, 4, 6, 7, 10

Internal errors

Failures inside Node.js bootstrapping or its own exception handler. Code 7 can also appear when an 'uncaughtException' handler itself throws. Extremely rare outside Node.js development.

5

Fatal error

An unrecoverable error in V8, with FATAL ERROR printed on stderr.

8

Unused

Older releases used it for some uncaught exceptions.

9

Invalid argument

An unknown option, or an option that needs a value without one. Observed also for a missing file given to --env-file.

12

Invalid debug argument

--inspect or --inspect-brk with a port that is invalid or unavailable.

13

Unsettled top-level await

await at the top level on a promise that never settles, so the loop empties with the module still pending.

14

Snapshot failure

Node.js was building a V8 startup snapshot and the application state did not meet the requirements.

greater than 128

Signal exits

Death by a fatal signal reports 128 plus the signal number by POSIX convention: SIGABRT (6) gives 134.

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.

Seeing the status of thirteen different endings
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);
}
Output on Node.js 26, 24 and 22 (identical on all three)
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.

Same 4 MiB of output, two ways of ending (observed on macOS with stdout connected to a pipe)
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);
}
Output on Node.js 26, 24 and 22
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.exitCode and 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) or ERR_OUT_OF_RANGE (1.5), both of which were observed.

The 'beforeExit' and 'exit' events

'beforeExit' and 'exit' look similar and have opposite jobs.

Table 5. Comparison of the two events
'beforeExit' 'exit'

Fires when

The event loop is empty and nothing more is scheduled.

The process is about to end: after process.exit(), after an uncaught exception, or after 'beforeExit' when no new work appeared.

Not emitted for

process.exit(), uncaught exceptions, or death by a signal.

Death by a signal with its default action (observed: no event).

Argument

The current process.exitCode.

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 process.exitCode is the only way to still change the outcome.

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.

Re-arming beforeExit, and the limits of exit
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');
Output on Node.js 26, 24 and 22, and the shell status afterwards
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".

Which events fire for which ending
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.

Releasing a resource at exit unless the owner was collected first
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);
Output on Node.js 26, 24 and 22 (the 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:

Table 6. The signals you will meet, and what Node.js does with them
Signal Typical sender Behaviour in Node.js

SIGINT

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.

SIGTERM

kill pid, docker stop, Kubernetes, service managers, process.kill(pid) (the default signal).

Same default as SIGINT (shell status 143). Not supported on Windows, although a listener can be installed.

SIGHUP

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.

SIGUSR1

kill -USR1 pid.

Reserved by Node.js to start the inspector. A listener can be installed but may interfere with the debugger. See SIGUSR1.

SIGUSR2

kill -USR2 pid.

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 --report-on-signal and a free signal for your application, for example to reload configuration.

SIGPIPE

Writing to a closed pipe or socket.

Ignored by default; can have a listener.

SIGKILL, SIGSTOP

kill -9 pid, job control.

Cannot have a listener: process.on('SIGKILL', …​) throws (observed EINVAL, uv_signal_start). SIGKILL always ends the process at once, without 'exit' events or any cleanup.

SIGBREAK

Ctrl+Break on Windows.

Delivered on Windows; can be listened on elsewhere but there is no way to send it.

SIGWINCH

The terminal was resized.

Delivered when the console size changes. On Windows only on a write that moves the cursor or in raw mode.

SIGBUS, SIGFPE, SIGSEGV, SIGILL

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 kill(2).

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.

Handling a signal, and testing that a process exists
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);
Output on Node.js 26, 24 and 22 (the macOS signal numbers on the first line differ on Linux)
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.

A signal handler alone does not keep the process alive
// 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.

Default actions, observed from a shell
#!/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) }"
Output on Node.js 26, 24 and 22 (macOS)
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 SIGUSR1 handler: you may break the debugger. Use SIGUSR2 for your own purposes.

Starting the inspector from outside, and switching the feature off
#!/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
Output on Node.js 26 for the three invocations (24 and 22 behaved the same)
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.

Capturing a diagnostic report from a running process
#!/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
Output on Node.js 26 (24 and 22 only differ in the timestamp and pid in the file name)
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.

Table 7. Signals and process.kill() on Windows (from the Node.js documentation; not executed here)
Item Behaviour

SIGINT

Supported from the terminal (Ctrl+C, unless the console is in raw mode).

SIGBREAK

Delivered when Ctrl+Break is pressed.

SIGHUP

Generated when the console window is closed; Windows ends the process unconditionally about 10 seconds later, so cleanup must be quick.

SIGTERM

Not supported, although a listener can be installed.

SIGWINCH

Delivered on a console resize, but only on a write that moves the cursor or in raw mode with a readable TTY.

process.kill(pid, 'SIGINT' | 'SIGTERM' | 'SIGKILL') and subprocess.kill()

Emulated: the target is terminated unconditionally (no handler runs) and the parent sees it as killed by signal. Signal 0 still works as an existence test.

process.kill() with a process group pid

Throws on Windows.

SIGUSR1, SIGUSR2, --report-signal

POSIX signals; the report signal is documented as not supported on Windows. Use --inspect to debug and an explicit mechanism (an HTTP endpoint, a file watcher, a named pipe) to trigger actions.

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.

Three ways to run Node.js as PID 1, and what SIGTERM does to each
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
Observed result of the three runs, in order
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 (SIGINT for the developer’s Ctrl+C, SIGTERM for 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 call process.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).

A reusable shutdown helper (shutdown.js) for the Bookshelf server
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);
}
A self-contained server entry point that uses it (serve.js)
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.

Driving the shutdown from outside
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);
Output on Node.js 26 (the port differs on every run, and the 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

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 any exitCode. With a listener the default is replaced, and the process continues and exits with 0 unless you set process.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. The origin argument 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 is throw: 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 (and process.addUncaughtExceptionCaptureCallback(), Experimental since v25.9.0, allows several).

Table 8. The --unhandled-rejections modes
Mode Behaviour

throw (default)

Emit 'unhandledRejection'. If no listener exists, raise the rejection as an uncaught exception.

strict

Raise the rejection as an uncaught exception; if that exception is handled, 'unhandledRejection' is emitted.

warn

Always print a warning, whether or not a listener exists; no exit.

warn-with-error-code

Emit 'unhandledRejection'; if there is no listener, print a warning and set the exit code to 1.

none

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.

One failure, seven policies (last-resort.js, the child)
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');
The driver that runs the child in different ways
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');
Output on Node.js 26 (24 and 22 print the same statuses)
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.

Portable feature detection, and the difference from require
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);
Output on Node.js 26, 24 and 22
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.

Reading the build features
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,
});
Output on Node.js 26, 24 and 22
{
  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');
Output on Node.js 26 (stderr lines first, then the event lines; on 24 and 22 identical)
(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);
Output on Node.js 26, 24 and 22 (the 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'));
}
Output on Node.js 26, 24 and 22, without and with 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

Table 9. The mistakes this page prevents
Mistake What to do instead

process.exit(1) right after writing output

Set process.exitCode = 1 and let the process drain; output to a pipe can be truncated otherwise.

An 'uncaughtException' listener that logs and carries on

Log synchronously, set a non-zero exitCode and let the process end; restart it from outside. Without the code the crash is reported as success.

A SIGINT or SIGTERM listener that never ends the process

Installing a listener removes the default. Always finish by letting the loop empty (with exitCode) or, in a watchdog, calling process.exit().

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 SIGKILL, or expecting cleanup after it

Impossible. Design for crash safety (idempotent work, transactions) and use SIGTERM for graceful stops.

Starting Node.js as PID 1 in a container without handlers, or through npm start

Use exec-form CMD ["node", "server.js"], install a SIGTERM handler, or use docker run --init.

Treating process.env.FLAG as a boolean or number

Values are strings; "false" is truthy. Compare explicitly and parse numbers.

Writing env.X = undefined to "unset" a variable

Use delete env.X.

`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 process.chdir() in a library

Anchor paths with import.meta.dirname instead.

Measuring durations with Date.now()

Use process.hrtime.bigint() or performance.now(); the wall clock can jump.

Reading memoryUsage().heapUsed to find a Buffer leak

Watch rss, external and arrayBuffers as well.

Relying on 'beforeExit' for cleanup

It does not fire for process.exit(), crashes or signals; use 'exit' for synchronous cleanup and the signal handler for asynchronous cleanup.

Calling process.exit() from a library

Throw or reject; the application decides when to end.

Installing a SIGUSR1 handler

The inspector uses it. Use SIGUSR2, and consider --disable-sigusr1 in hardened deployments.

What changed recently

Table 10. Process-related changes and when they landed
Version Change Effect on you

Node.js 20.0.0

process.exit() and process.exitCode accept only integers and integer strings (DEP0164 reaches End-of-Life).

Wrong types throw instead of being coerced.

Node.js 15.0.0

The default of --unhandled-rejections becomes throw.

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

process.getBuiltinModule() added.

Synchronous, import-free access to built-ins in portable code.

Node.js 22.5.0

process.finalization added (Active development).

Object-lifetime cleanup at exit.

Node.js 22.10.0 and 23.0.0

process.features.typescript and process.features.require_module added.

Tools can detect type stripping and require(esm).

Node.js 22.13.0 and 23.4.0

process.features.ipv6, .uv, .tls_alpn, .tls_ocsp and .tls_sni deprecated.

Stop checking them.

Node.js 22.14.0 and 23.7.0

--disable-sigusr1 added (not experimental since 22.20.0 and 24.8.0).

You can switch off signal-triggered inspector start.

Node.js 22.15.0 and 23.11.0

process.execve() added (Experimental); in 26.1.0 a failing call throws instead of aborting.

Replace the process image from JavaScript on POSIX.

Node.js 22.16.0 and 24.0.0

process.availableMemory() and process.constrainedMemory() become Stable.

Safe to use for container-aware limits.

Node.js 22.21.0 and 24.10.0

--env-file, --env-file-if-exists, process.loadEnvFile() and util.parseEnv() are no longer experimental.

dotenv is no longer needed for the common case.

Node.js 24.12.0 and 25.2.0

Type stripping is stable; process.features.typescript is 'strip' by default.

Check the value rather than a version.

Node.js 25.9.0

process.addUncaughtExceptionCaptureCallback() added (Experimental).

Several capture callbacks can coexist.

Node.js 26.0.0

The transform value of process.features.typescript is removed (with --experimental-transform-types).

Only 'strip' or false remain.

Node.js 22.23.3, 24.21.0, 26.10.0

Current patch releases of the three lines.

Run node -p process.version to see yours.

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.

Table 11. Quick corrections for older tutorials and books
Statement you may meet Current situation

"Use dotenv to load `.env`"

node --env-file=.env or process.loadEnvFile() are built in and stable since v24.10.0 and v22.21.0.

"Call process.exit() when you are done"

Set process.exitCode; exit() can truncate output and abandons pending work.

"`process.exit('abc')` or process.exit({}) are tolerated"

Only integers and integer strings are accepted since v20.0.0.

"An unhandled rejection only prints a warning"

The default is throw since v15.0.0: it crashes with exit code 1.

"`process.hrtime()` returns the time"

Use process.hrtime.bigint(); the tuple form is Legacy.

"`process.mainModule` is the entry module"

Deprecated since v14.0.0; use require.main (CommonJS) or import.meta.main (ES modules, v24.2.0 and v22.18.0).

"SIGTERM works on Windows"

It does not; use SIGINT, SIGBREAK and an application-level shutdown message.

"`process.env.DEBUG = false` disables debugging"

It stores the truthy string "false".

References