npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

smart-shutdown

v3.0.0

Published

Graceful shutdown and crash reporting for Node.js services, wired to the Health Check platform.

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-shutdown

The 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 });   // seconds

The 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: true skips 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. Use getShutdownInstance().requestShutdown(reason) instead.