smart-shutdown
v3.0.0
Published
Graceful shutdown and crash reporting for Node.js services, wired to the Health Check platform.
Maintainers
Readme
smart-shutdown
Graceful shutdown and crash reporting for Node.js services, wired to the Health Check platform. Works in both CommonJS and ESM.
Four things, all optional:
| Feature | What it does |
|---|---|
| shutdown() | Drains in-flight HTTP requests, runs your cleanup handlers, reports the restart, exits. |
| healthCheck() | Periodic "still alive" ping for services the platform cannot reach itself. |
| serverError() | Reports uncaught exceptions, unhandled rejections and request errors. |
| scheduleShutdown() | Restarts after a delay if a backing service never comes back. |
Install
npm install smart-shutdownThe one rule
Create one ShutdownHelper per process, at application level, and share it.
Every feature installs process-wide state — signal handlers, crash listeners, a
heartbeat. Creating a second helper used to install a second set of signal
handlers that drained the same cleanup list and raced to call process.exit();
the visible symptom was a configured 20-second drain window that actually lasted
about a second, with every cleanup handler running twice. That is now guarded —
a second call warns and returns the first instance — but the guard is a safety
net, not a design to build on.
// src/shutdown.js — one module owns it
import { ShutdownHelper } from 'smart-shutdown';
export const helper = new ShutdownHelper({
apiUrl: process.env.HC_API_BASE, // e.g. https://healthapi.example.com/v1
service: process.env.HC_SERVICE_ID,
token: process.env.HC_PROJECT_TOKEN,
development: process.env.NODE_ENV !== 'production',
log: (level, message) => logger[level]?.(message) ?? console.log(level, message),
});Everything else imports helper from there.
Graceful shutdown
import { shutdownHandlerFn } from 'smart-shutdown';
import { helper } from './shutdown.js';
const server = app.listen(3000);
helper.shutdown({ server, timeout: 20000 });
// Register cleanup from wherever the resource lives. Runs in registration order.
shutdownHandlerFn(() => mongoose.connection.close(), 'mongo');
shutdownHandlerFn(() => redis.quit(), 'redis');On SIGINT/SIGTERM: stop accepting connections → finish in-flight requests
(up to timeout) → report the shutdown → run cleanup handlers in order → exit.
It also reports a startup event at boot. That pairing is what makes the data worth keeping: a shutdown alone says only "it stopped", while a shutdown paired with the next startup gives the real downtime — and a startup with no preceding shutdown is the signature of a process that was killed rather than asked to stop (SIGKILL, OOM, an evicted container), which a shutdown hook can never report on its own. Startup events are recorded, never alerted on.
| Option | Default | Description |
|---|---|---|
| server | – | HTTP/HTTPS/Express/Koa/Fastify server to drain. |
| timeout | 30000 | Milliseconds allowed for connections to drain. |
| handlerTimeout | = timeout | Cap on a single cleanup handler, so one hung close() cannot hang the shutdown. |
| drainDelay | 1000 | Pause after handlers finish, for anything still flushing. |
| reportTimeout | 5000 | Milliseconds before the shutdown report is abandoned. |
| development | false | Skip graceful shutdown and all reporting. |
| forceExit | true | Call process.exit once draining completes. |
| reportStartup | true | Announce the process start, so restarts can be paired. |
| finally | – | Runs last. Keep it short and synchronous. |
| log | console.log | (level, message) => void |
shutdownHandlerFn(fn, name) returns an unregister function. Registering the
same function twice is a no-op — it happens when a module is imported down two
paths, and closing the same connection twice usually throws on the second. A
fn that is not a function is logged and skipped rather than thrown, so a bad
registration can never stop the application from booting.
Restarting after an outage
When a backing service drops out, neither obvious response is right. Exiting on
the first disconnected event turns a three-second blip into a cold start —
mongoose and ioredis reconnect on their own and buffer commands while they do —
and during a real outage it becomes a crash loop against a database that is still
down. Ignoring it leaves a process that cannot serve traffic.
scheduleShutdown is the middle: wait, and restart only if it never came back.
import { scheduleShutdown, cancelShutdown } from 'smart-shutdown';
const RESTART_AFTER = Number(process.env.MONGO_RESTART_AFTER_MS) || 120000;
mongoose.connection.on('disconnected', () => {
scheduleShutdown('mongo', { delay: RESTART_AFTER, reason: 'connection lost' });
});
mongoose.connection.on('connected', () => {
cancelShutdown('mongo');
});Nothing has to be registered up front and there is no monitor object to thread through your modules — import the two functions wherever the resource lives.
Scheduling the same key twice is a no-op. The countdown measures from the
first failure, not the most recent event, which matters because drivers re-emit
disconnected on every retry and a timer restarted on each one never expires.
When it fires it goes through requestShutdown() — the same path a signal takes,
so connections close, cleanup handlers run and the restart is reported once
rather than in a loop. Your supervisor (pm2, systemd Restart=always,
Kubernetes) brings it back. Exit code is 1, so Restart=on-failure applies.
| Option | Default | Description |
|---|---|---|
| delay | 120000 | Milliseconds to wait before shutting down. |
| reason | "<key>" unavailable | Human-readable cause, recorded on the report. |
| exitCode | 1 | Passed through to requestShutdown. |
| signal | SIGINT | What the platform records. |
cancelShutdown(key) cancels one; cancelShutdown() cancels every pending one,
and a real shutdown does that for you. pendingShutdowns() lists the keys
currently counting down — useful in a health endpoint:
app.get('/health', (req, res) => {
const failing = pendingShutdowns();
res.status(failing.length ? 503 : 200).json({ healthy: !failing.length, failing });
});A restart cannot repair a database that is down, so this is worth reaching for only when the process holds state that goes bad after a reconnect, or when a supervisor restart is genuinely cheaper than a stuck worker. If the countdown fires with no shutdown handler initialised it logs an error and stays up, rather than killing the process without closing anything.
Health pings
For a service the platform cannot reach on a URL — a worker, a consumer, anything behind a network the health checker cannot cross.
helper.healthCheck({ interval: 30 }); // secondsThe platform declares the service dead when the last ping is older than
interval * threshold seconds, so the cadence has to be exact.
| Option | Default | Description |
|---|---|---|
| interval | 30 | Seconds between pings. Any value; no cron rounding. |
| immediate | true | Ping once at startup, surfacing a bad token at boot. |
Returns { stop, ping } (as a promise — every helper method is async). It
stops itself during shutdown. Calling it without await is fine if you do not
need the handle: the timer is armed before the call yields.
Error reporting
app.use(await helper.serverError());Mount it last, after your routes. Every helper method is async — that is how
2.x declared them and it is kept so .then(...) still works — so await the
call before handing the middleware to app.use().
By default it answers the request itself with
500 {"error":"Internal Server Error"}, which is what 2.x did and therefore what
your clients already expect. Pass respond: false to have it call next(err)
instead and let your own error handler format the response.
There is nothing to configure about identity — no repository, no application name,
no environment. Which repo and branch the stack traces map to comes from the
GitLab connection on the service, and the display name from the project
record, both set once in the platform UI. The service id you already configured is
the whole identity, and a second copy of any of it in a .env file is the copy
that goes stale after a rename.
It also installs one uncaughtException and one unhandledRejection listener
per process — once, however many times serverError() is called.
After an uncaught exception it reports the crash and, by default, keeps the
process running. That is not the right behaviour and it is not Node's: a
listener suppresses Node's default exit, so the application carries on over a
half-finished write or a lock it never released. It is the default only because
2.x behaved this way, and flipping it during an upgrade would change whether your
process stays up. Set exitOnUncaught: true once you are ready — the
shutdown then runs through the graceful path, connections close, and the platform
can pair the crash with the restart that follows.
An unhandled rejection is reported but never exits, either way: it is far
more often a forgotten .catch() on a background task than genuine state
corruption.
| Option | Default | Description |
|---|---|---|
| exitOnUncaught | false | Exit after an uncaught exception, as Node would. Recommended: true. |
| respond | true | Answer with a 500 instead of delegating to next(err). |
For a worker with no Express stack, await helper.serverlessError() installs
the process listeners without a middleware.
Also exported
import { getShutdownInstance, pendingShutdowns } from 'smart-shutdown';The active Shutdown for this process, or null. Useful in a module that needs
to trigger a shutdown without threading the helper through.
Notes
development: trueskips graceful shutdown and every network report.- All reporting fails soft and logs the HTTP status. A wrong service token
produces
Failed to report shutdown: … (HTTP 404)rather than silence — that distinction is the difference between "the platform is down" and "this service has never successfully reported anything". - A
process.exit()in your own crash handler emits no signal, so no shutdown is reported and the platform never learns the service restarted. UsegetShutdownInstance().requestShutdown(reason)instead.
