exit-hook and signal-exit are both Node.js utilities designed to run cleanup code right before a process terminates, but they operate on fundamentally different mechanisms. exit-hook registers callbacks that fire when the process is about to exit gracefully, covering standard exits and uncaught exceptions. signal-exit listens for specific POSIX signals (like SIGINT or SIGTERM) sent to the process, offering lower-level control over how the application responds to external termination requests. While exit-hook focuses on high-level lifecycle events, signal-exit provides granular access to the signals triggering those events.
When building robust Node.js applications, ensuring clean shutdowns is critical. You need to close database connections, flush write streams, and remove temporary files before the process dies. Two popular packages help with this: exit-hook and signal-exit. While they sound similar, they solve the problem from different angles. Let's dig into how they work and when to use each.
exit-hook acts as a high-level lifecycle manager. It registers functions that run automatically when the process is about to exit. It handles the complexity of listening to various exit events (normal exit, uncaught exceptions) behind the scenes so you don't have to.
// exit-hook: Register a cleanup function
import exitHook from 'exit-hook';
exitHook(() => {
console.log('Cleaning up resources...');
dbConnection.close();
});
// The function runs whether the app exits normally or crashes
process.exit(0);
signal-exit works at a lower level by listening directly to POSIX signals sent to the process. It tells you why the process is exiting by providing the signal name and exit code. This gives you more context but requires you to handle the logic yourself.
// signal-exit: Listen for specific signals
import onExit from 'signal-exit';
const unsubscribe = onExit((code, signal) => {
if (signal === 'SIGINT') {
console.log('User interrupted the process (Ctrl+C)');
} else if (signal === 'SIGTERM') {
console.log('System requested termination');
}
// Perform cleanup based on the signal
dbConnection.close();
});
// You can also manually trigger logic based on signals
process.kill(process.pid, 'SIGTERM');
One of the biggest challenges in Node.js is cleaning up after a crash.
exit-hook automatically covers uncaught exceptions. If your code throws an error that isn't caught, the registered hooks still fire. This is a huge win for reliability, ensuring your app doesn't leave dangling resources even when it crashes unexpectedly.
// exit-hook: Works even with uncaught exceptions
import exitHook from 'exit-hook';
exitHook(() => {
console.log('This runs even if the app crashes below!');
});
throw new Error('Something went wrong');
// Output: "This runs even if the app crashes below!"
signal-exit primarily reacts to signals. While it handles graceful shutdowns well, its behavior with uncaught exceptions depends on how the Node.js runtime terminates. It is excellent for planned shutdowns but might not catch every abrupt crash scenario as seamlessly as exit-hook without extra configuration.
// signal-exit: Best for signal-driven exits
import onExit from 'signal-exit';
onExit((code, signal) => {
// If the process dies from an uncaught exception without a signal,
// 'signal' might be null, and 'code' will be the exit code.
console.log(`Exiting with code: ${code}, signal: ${signal}`);
});
// More reliable for planned terminations than random crashes
Sometimes your cleanup logic needs to change based on how the app stopped. For example, you might want to send a different alert if a user manually stopped the service versus if the system ran out of memory.
exit-hook does not provide this detail. It simply says "we are leaving now." It is an all-or-nothing approach.
// exit-hook: No details on why we are exiting
import exitHook from 'exit-hook';
exitHook(() => {
// We don't know if this was a crash, a manual exit, or a signal
cleanupEverything();
});
signal-exit shines here. It passes the code and signal arguments to your callback, allowing you to branch your logic.
// signal-exit: Detailed exit context
import onExit from 'signal-exit';
onExit((code, signal) => {
if (signal === 'SIGUSR1') {
// Maybe dump a heap snapshot instead of just closing
generateHeapSnapshot();
} else {
// Standard cleanup for other exits
closeServer();
}
});
It is crucial to note that exit-hook is deprecated. The maintainer has marked it as legacy. While it still works, using deprecated packages in new projects introduces risk. The functionality it provided is often better handled by modern Node.js features or more actively maintained alternatives like signal-exit or native process event listeners.
// ⚠️ Do not use in new projects
import exitHook from 'exit-hook'; // Deprecated
// ✅ Prefer native events or signal-exit
process.on('exit', (code) => {
// Native Node.js exit event
console.log(`Process exiting with code: ${code}`);
});
signal-exit remains actively maintained and is the recommended choice for robust signal handling in modern Node.js environments.
You have a basic API server and just need to close the DB connection before the server stops.
process.on('exit') or signal-exitexit-hook is deprecated. Native events are sufficient for simple cases.// Using native Node.js (no extra dependency)
process.on('exit', () => {
db.close();
});
You are building a command-line tool that watches files. When a user hits Ctrl+C (SIGINT), you want to finish the current file write before exiting.
signal-exitSIGINT specifically to handle the interrupt gracefully.import onExit from 'signal-exit';
let isWriting = false;
onExit((code, signal) => {
if (signal === 'SIGINT' && isWriting) {
console.log('Finishing current write before exiting...');
// Wait for write to finish
}
});
You want to log a specific message only when the process is killed by the OS (e.g., OOM killer sending SIGKILL or SIGTERM).
signal-exitimport onExit from 'signal-exit';
onExit((code, signal) => {
if (signal) {
logAlert(`Process killed by signal: ${signal}`);
}
});
| Feature | exit-hook | signal-exit |
|---|---|---|
| Status | ❌ Deprecated | ✅ Active |
| Primary Input | Callback function | Callback with (code, signal) |
| Exit Context | None (Generic) | Detailed (Signal name, Exit code) |
| Uncaught Exceptions | ✅ Handled automatically | ⚠️ Depends on signal presence |
| Complexity | Low (Fire and forget) | Medium (Requires signal logic) |
| Best For | Legacy simple cleanup | Modern CLI tools, Daemons, Debugging |
Avoid exit-hook in any new project due to its deprecated status. Its simplicity is no longer worth the risk of using unmaintained code.
For most modern applications, start with native Node.js events (process.on('exit')) if you only need basic cleanup. If you need to distinguish between different types of shutdowns (like user interrupts vs. system errors) or need reliable signal handling across platforms, signal-exit is the professional choice. It gives you the visibility and control needed to build resilient backend services and CLI tools.
Choose signal-exit if you need to detect exactly which signal caused the process to terminate (e.g., distinguishing between a user interrupt SIGINT and a system kill SIGTERM) or if you need to perform specific actions based on that signal. It is better suited for CLI tools, daemons, or complex systems where the reason for exit dictates the cleanup strategy or logging behavior.
Choose exit-hook if you need a simple, high-level way to run cleanup tasks (like closing database connections or flushing logs) whenever your Node.js process exits, regardless of the cause. It is ideal for standard applications where you just want to ensure resources are released without worrying about specific signal types or complex signal handling logic.
When you want to fire an event no matter how a process exits:
process.exit(code) called.process.kill(pid, sig) called.Use signal-exit.
// Hybrid module, either works
import { onExit } from 'signal-exit'
// or:
// const { onExit } = require('signal-exit')
onExit((code, signal) => {
console.log('process exited!', code, signal)
})
remove = onExit((code, signal) => {}, options)
The return value of the function is a function that will remove the handler.
Note that the function only fires for signals if the signal would cause the process to exit. That is, there are no other listeners, and it is a fatal signal.
If the global process object is not suitable for this purpose
(ie, it's unset, or doesn't have an emit method, etc.) then the
onExit function is a no-op that returns a no-op remove method.
alwaysLast: Run this handler after any other signal or exit
handlers. This causes process.emit to be monkeypatched.If the handler returns an exact boolean true, and the exit is a
due to signal, then the signal will be considered handled, and
will not trigger a synthetic process.kill(process.pid, signal) after firing the onExit handlers.
In this case, it your responsibility as the caller to exit with a
signal (for example, by calling process.kill()) if you wish to
preserve the same exit status that would otherwise have occurred.
If you do not, then the process will likely exit gracefully with
status 0 at some point, assuming that no other terminating signal
or other exit trigger occurs.
Prior to calling handlers, the onExit machinery is unloaded, so
any subsequent exits or signals will not be handled, even if the
signal is captured and the exit is thus prevented.
Note that numeric code exits may indicate that the process is already committed to exiting, for example due to a fatal exception or unhandled promise rejection, and so there is no way to prevent it safely.
The 'signal-exit/browser' module is the same fallback shim that
just doesn't do anything, but presents the same function
interface.
Patches welcome to add something that hooks onto
window.onbeforeunload or similar, but it might just not be a
thing that makes sense there.