exit-hook vs node-cleanup vs signal-exit
Graceful Shutdown and Signal Handling in Node.js Applications
exit-hooknode-cleanupsignal-exit

Graceful Shutdown and Signal Handling in Node.js Applications

exit-hook, node-cleanup, and signal-exit are utilities designed to run specific code right before a Node.js process terminates. They address the common need to perform cleanup tasks—such as closing database connections, deleting temporary files, or flushing logs—when an application exits. While they share a similar goal, they differ significantly in reliability, scope, and maintenance status. signal-exit is the industry-standard, robust solution that intercepts OS signals directly. exit-hook is a simpler, lighter alternative for basic use cases but lacks deep signal handling. node-cleanup is an older utility that is no longer recommended for new projects due to lack of maintenance and limited signal coverage compared to modern alternatives.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
exit-hook030113.2 kB07 months agoMIT
node-cleanup0163-910 years agoMIT
signal-exit019977 kB113 years agoISC

Graceful Shutdowns: exit-hook vs node-cleanup vs signal-exit

When building Node.js applications, especially long-running servers or command-line tools, things don't always end cleanly. Users might hit Ctrl+C, the OS might send a termination signal, or an unhandled error might crash the process. In these moments, you need to run cleanup code—closing database pools, removing temp files, or saving state. Three packages often come up for this: exit-hook, node-cleanup, and signal-exit. Let's dig into how they actually work and which one belongs in your project.

🛑 How They Catch the Exit Event

The core difference lies in what triggers they listen to. Node.js has a built-in process.on('exit') event, but it has a major flaw: it doesn't fire if the process is killed by a signal (like kill -9 or sometimes even Ctrl+C depending on how it's handled).

signal-exit goes deeper. It listens to specific OS signals (SIGINT, SIGTERM, SIGHUP, etc.) directly. When a signal arrives, it runs your handlers before letting the process die. This makes it much more reliable for catching unexpected shutdowns.

// signal-exit: Listens to OS signals directly
const onExit = require('signal-exit');

onExit((code, signal) => {
  console.log(`Process exiting due to signal: ${signal}`);
  // Perform sync cleanup here
  fs.unlinkSync('/tmp/my-lock-file');
}, { alwaysLast: true });

exit-hook is simpler. It wraps the standard process.on('exit') and adds a basic listener for SIGINT (Ctrl+C). It's great for simple scripts but might miss other signals like SIGTERM (often sent by Docker or Kubernetes).

// exit-hook: Wraps process.exit and basic SIGINT
const exitHook = require('exit-hook');

exitHook(() => {
  console.log('Exiting normally or via Ctrl+C');
  // Sync cleanup only
  db.closeSync();
});

node-cleanup works similarly to exit-hook but is an older implementation. It registers handlers for exit and a few common signals, but its list of covered signals is not as comprehensive as signal-exit, and it hasn't been updated to match modern Node.js behaviors.

// node-cleanup: Older signal handling
const nodeCleanup = require('node-cleanup');

nodeCleanup((exitCode, signal) => {
  console.log('Cleanup running');
  // Returns true to let process exit, false to stay alive (rarely used)
  return true;
});

⚡ Sync vs Async: The Hard Limit

Here is a critical constraint that applies to all three packages: You cannot run asynchronous code reliably during process exit.

When Node.js is shutting down, the event loop is often already torn down or in a state where it won't process new microtasks. If you try to await a database close or a file write, it simply won't happen before the process dies.

All three libraries force you to use synchronous methods. If you see examples using async/await inside these hooks, they are misleading.

// ❌ WRONG: This will likely NOT complete
exitHook(async () => {
  await db.close(); // Promise resolves after process is already gone
});

// ✅ RIGHT: Use synchronous equivalents
exitHook(() => {
  db.closeSync(); // Must be a blocking, sync call
  fs.writeFileSync('log.txt', 'Shutdown complete');
});

This limitation means your cleanup logic must be fast and blocking. signal-exit is particularly strict about this, ensuring handlers run in the final ticks of the event loop.

📡 Signal Coverage: What Triggers Cleanup?

The reliability of your shutdown logic depends on which signals trigger it. Different environments send different signals.

  • Local Dev (Ctrl+C): Sends SIGINT.
  • Docker Stop / Kubernetes: Sends SIGTERM.
  • System Reboot / Daemon Managers: Might send SIGHUP.

signal-exit covers the widest range. It explicitly handles SIGINT, SIGTERM, SIGHUP, SIGQUIT, and others. This makes it the only safe choice for containerized environments (Docker/K8s) where SIGTERM is the standard shutdown signal.

// signal-exit handles SIGTERM (common in Docker)
onExit((code, signal) => {
  if (signal === 'SIGTERM') {
    // Graceful shutdown logic for K8s
    cleanup();
  }
});

exit-hook primarily targets SIGINT and normal exits. While it works fine for local CLI tools, it might not catch a SIGTERM sent by a process manager unless you configure your environment to translate it, which is risky.

// exit-hook: Mostly focuses on SIGINT and normal exit
// May miss SIGTERM in some configurations
exitHook(() => {
  // Runs on Ctrl+C, but maybe not on `docker stop`
});

node-cleanup handles a few standard signals but lacks the extensive testing and coverage map of signal-exit. In edge cases involving rare signals or specific OS behaviors, it might fail to trigger.

🗑️ Maintenance and Ecosystem Trust

Trust is a huge factor when picking infrastructure code. You want a library that is battle-tested.

  • signal-exit is heavily relied upon by the core npm CLI itself. It has a large user base, frequent audits, and is considered the "correct" way to handle exits in the Node.js community. It is actively maintained.
  • exit-hook is maintained and stable but serves a narrower niche. It's fine for small tools but doesn't have the same level of deep-system integration as signal-exit.
  • node-cleanup is effectively deprecated. It hasn't seen significant updates in years. Using it introduces unnecessary risk when a superior, actively maintained alternative (signal-exit) exists.

🏗️ Real-World Usage Patterns

Scenario 1: A CLI Tool for Developers

You are building a tool that scaffolds a project. If the user hits Ctrl+C during installation, you want to delete the partial files.

  • Best Choice: exit-hook
  • Why? It's lightweight, zero-dependency, and perfectly handles the Ctrl+C case which is 99% of what CLI users do.
const exitHook = require('exit-hook');
const fs = require('fs');

const tempDir = './temp-setup';

exitHook(() => {
  if (fs.existsSync(tempDir)) {
    fs.rmSync(tempDir, { recursive: true, force: true });
  }
});

Scenario 2: A Backend API in Docker

You run an Express server in Kubernetes. When deploying a new version, K8s sends SIGTERM. You must finish current requests and close DB connections before dying.

  • Best Choice: signal-exit
  • Why? It guarantees catching SIGTERM. You can combine it with your own graceful shutdown logic.
const onExit = require('signal-exit');
const server = require('./app');

onExit((code, signal) => {
  console.log(`Received ${signal}, shutting down gracefully...`);
  
  // Must be sync or very carefully managed async before loop dies
  // Ideally, you trigger a graceful shutdown flow that blocks exit
  server.closeSync(); 
  process.exit(code);
});

Scenario 3: Legacy Script Maintenance

You find an old script using node-cleanup.

  • Action: Refactor to signal-exit.
  • Why? There is no benefit to keeping node-cleanup. Moving to signal-exit improves signal coverage and future-proofs the script.
// Old code
// const nodeCleanup = require('node-cleanup');
// nodeCleanup(() => { ... });

// New code
const onExit = require('signal-exit');
onExit(() => { 
  // More reliable cleanup 
});

📊 Summary Comparison

Featureexit-hooknode-cleanupsignal-exit
Primary UseSimple CLIsLegacy ScriptsProduction Services / Complex CLIs
Signal CoverageLow (SIGINT, Exit)Medium (Common Signals)High (SIGINT, SIGTERM, SIGHUP, etc.)
Async Support❌ No❌ No❌ No
MaintenanceActiveInactive / DeprecatedActive (Used by npm)
DependenciesZeroZeroLow (Robust)
Docker/K8s Ready⚠️ Risky❌ No✅ Yes

💡 The Bottom Line

For almost all professional use cases, signal-exit is the right choice. It solves the hard problems of OS signal handling that exit-hook and node-cleanup gloss over. The only time you should reach for exit-hook is when you are building a tiny, dependency-free CLI tool where bundle size and simplicity matter more than handling obscure OS signals.

Avoid node-cleanup entirely. It is a solved problem with better solutions available. When architecting your Node.js applications, relying on the most robust signal handler ensures your cleanup logic actually runs when it matters most — preventing data corruption and leaving your system in a clean state.

How to Choose: exit-hook vs node-cleanup vs signal-exit

  • exit-hook:

    Choose exit-hook if you need a zero-dependency, lightweight solution for simple synchronous cleanup tasks in a CLI tool or script. It is best suited for scenarios where you only care about normal exits or standard interrupts (Ctrl+C) and do not need to handle complex OS signals like SIGHUP or SIGTERM. Avoid it for critical production services where guaranteed execution during forced kills or complex signal flows is required.

  • node-cleanup:

    Avoid choosing node-cleanup for new projects. While it historically offered a simple API for registering cleanup handlers, it is no longer actively maintained and does not cover the full range of POSIX signals that modern applications often need to handle. Its functionality is completely superseded by signal-exit, which offers better reliability, broader signal support, and active community backing.

  • signal-exit:

    Choose signal-exit for any production-grade Node.js application, especially backend services, daemons, or complex CLIs. It is the most reliable option because it listens to a wide array of OS signals (not just SIGINT) and ensures your cleanup code runs even in edge cases where other hooks might fail. It is the de facto standard in the ecosystem, used by major tools like npm itself, making it the safest bet for architectural stability.

README for exit-hook

exit-hook

Run some code when the process exits

The process.on('exit') event doesn't catch all the ways a process can exit.

This package is useful for cleaning up before exiting.

Install

npm install exit-hook

Usage

import exitHook from 'exit-hook';

exitHook(signal => {
	console.log(`Exiting with signal: ${signal}`);
});

// You can add multiple hooks, even across files
exitHook(() => {
	console.log('Exiting 2');
});

throw new Error('🦄');

//=> 'Exiting'
//=> 'Exiting 2'

Removing an exit hook:

import exitHook from 'exit-hook';

const unsubscribe = exitHook(() => {});

unsubscribe();

API

exitHook(onExit)

Register a function to run during process.exit.

Returns a function that removes the hook when called.

onExit

Type: (signal: number) => void

The callback function to execute when the process exits.

asyncExitHook(onExit, options)

Register a function to run during gracefulExit.

Returns a function that removes the hook when called.

Please see Async Notes for considerations when using the asynchronous API.

onExit

Type: (signal: number) => (void | Promise<void>)

The callback function to execute when the process exits via gracefulExit, and will be wrapped in Promise.resolve.

options

Type: object

wait

Type: number

The amount of time in milliseconds that the onExit function is expected to take. When multiple async handlers are registered, the longest wait time will be used.

import {asyncExitHook} from 'exit-hook';

asyncExitHook(async () => {
	console.log('Exiting');
}, {
	wait: 300
});

throw new Error('🦄');

//=> 'Exiting'

Removing an asynchronous exit hook:

import {asyncExitHook} from 'exit-hook';

const unsubscribe = asyncExitHook(async () => {
	console.log('Exiting');
}, {
	wait: 300
});

unsubscribe();

gracefulExit(signal?: number): void

Exit the process and make a best-effort to complete all asynchronous hooks.

If you are using asyncExitHook, consider using gracefulExit() instead of process.exit() to ensure all asynchronous tasks are given an opportunity to run.

import {gracefulExit} from 'exit-hook';

gracefulExit();

signal

Type: number

The exit code to use. Same as the argument to process.exit().

If not specified, the process will exit with process.exitCode if set, otherwise 0.

FAQ

Why don't my exit hooks run when using nodemon?

By default, nodemon uses SIGUSR2 to restart your app. Since SIGUSR2 is a user-defined signal, exit-hook does not handle it to avoid conflicts with your app logic.

Solution: Configure nodemon to use standard termination signals:

nodemon --signal SIGTERM your-app.js

Or in your nodemon.json:

{
	"signal": "SIGTERM"
}

Alternatively, you can handle SIGUSR2 in your app if you specifically need nodemon's default behavior:

// Handle nodemon restart signal
process.on('SIGUSR2', () => {
	gracefulExit();
});

Asynchronous Exit Notes

tl;dr If you have 100% control over how your process terminates, then you can swap exitHook and process.exit for asyncExitHook and gracefulExit respectively. Otherwise, keep reading to understand important tradeoffs if you're using asyncExitHook.

Node.js does not offer an asynchronous shutdown API by default #1 #2, so asyncExitHook and gracefulExit will make a "best effort" attempt to shut down the process and run your asynchronous tasks.

If you have asynchronous hooks registered and your Node.js process is terminated in a synchronous manner, a SYNCHRONOUS TERMINATION NOTICE error will be logged to the console. To avoid this, ensure you're only exiting via gracefulExit or that an upstream process manager is sending a SIGINT or SIGTERM signal to Node.js.

Asynchronous hooks should make a "best effort" to perform their tasks within the wait time, but also be written to assume they may not complete their tasks before termination.