Getting started
Install closeout, register one exit handler, and watch it run once on every way a Node.js program can end: exit, an emptied loop, a signal, a throw and a rejection.
closeout is one registry for everything a program has to do on the way out. You register a
handler with onExit, and it runs exactly once, whichever way the program ends, with a
record of how it ended.
Install
npm install closeoutIt has no dependencies: Node builtins only. It is ESM with a default condition, so
require('closeout') also works from CommonJS on Node 22.13+, and
closeout/signal-exit is CommonJS itself, as signal-exit is.
Importing the package attaches nothing to the process. The process-wide instance installs
on the first onExit, so a library that imports closeout only for its types pays nothing.
A first handler
import { onExit } from 'closeout';
onExit(({ path, code, signal, error }) => {
console.log(`leaving by ${path}: code ${code}, signal ${signal}, error ${error}`);
});
const door = process.argv[2];
if (door === 'exit') process.exit(3);
if (door === 'throw') throw new Error('mid-render');
if (door === 'reject') Promise.reject(new Error('never awaited'));
if (door === 'signal') {
setTimeout(() => {}, 10_000);
process.kill(process.pid, 'SIGTERM');
}
console.log('work done');Let the program finish, and the handler runs when the event loop empties (beforeExit),
where an async handler still has time to finish:
work done
leaving by beforeExit: code 0, signal null, error nullCall process.exit(3), and the handler runs on exit with the code you chose, in
report.code. (Node reads process.exitCode after the exit listeners, so a handler that
assigns it would change the code — leave it alone.)
leaving by exit: code 3, signal null, error nullSend a signal — it arrives once the synchronous work is done — and the handler runs before
the process dies of that signal. A parent
sees SIGTERM, not an exit code, which is how a shell, make or a CI runner tells a
cancelled job from a failed one:
work done
leaving by signal: code null, signal SIGTERM, error nullThrow, and the handler is told what was thrown; then the error is printed and the process exits 1, as Node would have done. (The stack trace is turned off here so this page can be checked byte for byte.)
leaving by uncaught: code 1, signal null, error Error: mid-render
[Error: mid-render]A promise nobody awaited is the fifth way out:
work done
leaving by rejection: code 1, signal null, error Error: never awaited
[Error: never awaited]Every output block on this site is checked: tests/examples.test.ts writes each titled file,
runs the command in the block's title, and compares both what it printed and how it ended.
The record
Every handler is handed one record:
| field | what it is |
|---|---|
path | 'exit', 'beforeExit', 'signal', 'uncaught' or 'rejection' |
signal | the signal that ended the program, or null |
code | the exit code it is leaving with, or null for a signal |
error | what was thrown or rejected, on those two paths; null otherwise |
The same record is what reportToJson() and reportToEvent() project, so a --json line
and an agent event cannot disagree with what the handler was told
(Reports for logs and agents).
onExit returns a function that takes the handler back out. A program that cleans up
normally can remove its handler and leave nothing for exit to do.
SIGKILL cannot be handled, by closeout or by anything else: the operating system does not
deliver it to a listener. The signals closeout listens for are SIGINT, SIGTERM, SIGHUP,
SIGQUIT and, on Windows, SIGBREAK.
Where next
- Guides: exit paths, phases, the deadline, the terminal, signals and exit status, reports, plugins.
- Why closeout: what it does that signal-exit, exit-hook and restore-cursor do not, cell by cell, with the evidence.
- Coming from signal-exit and the other two: change one import.
- API reference: every export of every entry point.
closeout
Runs every exit handler exactly once on every path out of the process, restores the terminal, and holds shutdown to a deadline so it cannot hang. Zero dependencies. Drop-in paths for signal-exit, exit-hook, restore-cursor, cli-cursor and onetime.
Every exit path, exactly once
The five ways a Node.js process ends, which ones can await, and how closeout runs each handler once when two fire together — and keeps going when one throws.