@ambicuity/shutdown-manager
v1.0.0
Published
Zero-drama shutdown orchestration for production Node.js HTTP services. Drain connections, stop new traffic, run cleanup hooks, and exit safely across Docker, Kubernetes, Express, Fastify, Koa, and native HTTP.
Maintainers
Keywords
Readme
@ambicuity/shutdown-manager
Zero-drama shutdown orchestration for production Node.js HTTP services. Drain connections, stop new traffic, run cleanup hooks, and exit safely across Docker, Kubernetes, Express, Fastify, Koa, and native HTTP/HTTP2.
Author: Ritesh Rana — [email protected]
Support development: buymeacoffee.com/ritesh.rana
Why?
server.close() does not actually drain your server. It stops accepting new
connections and waits for existing ones to close on their own — which, with
HTTP keep-alive, never happens until the client disconnects. Meanwhile
Kubernetes already started routing new requests away three seconds ago,
your Postgres pool is sitting on uncommitted work, and the SIGKILL clock
is ticking. That is how rolling deploys drop requests.
@ambicuity/shutdown-manager orchestrates the full shutdown lifecycle:
- Flip readiness to false so load balancers stop sending new traffic.
- Optionally wait for the LB to react (
preStopDelayMs). - Refuse new connections, set
Connection: closeon idle keep-alives, finish in-flight requests. - Run your cleanup hooks (Postgres, Redis, queues, telemetry flush) in priority order.
- Exit
0if everything was clean,1if a critical resource failed or a timeout fired.
Install
npm install @ambicuity/shutdown-managerRequires Node.js ≥ 18.17. Ships dual ESM + CJS with built-in TypeScript types.
Quickstart (TypeScript)
import express from 'express';
import { ShutdownManager } from '@ambicuity/shutdown-manager';
const app = express();
const server = app.listen(3000);
const shutdown = new ShutdownManager({ timeout: 15_000 })
.attach(server)
.register('postgres', () => pool.end(), { priority: 10, critical: true })
.register('redis', () => redis.quit())
.register('bullmq', () => worker.close());
app.get('/ready', (_req, res) =>
shutdown.isReady() ? res.send('ok') : res.status(503).send('shutting down'),
);That's it. SIGTERM and SIGINT are wired automatically. On signal, the manager walks the lifecycle and exits the process when it's done.
CommonJS
const { ShutdownManager } = require('@ambicuity/shutdown-manager');
const shutdown = new ShutdownManager().attach(server);Lifecycle
idle ─► preShutdown ─► draining ─► cleanup ─► done | failed
(flip ready, (server.close, (run resources (exit
optional drain sockets, by priority, 0 or 1)
preStop delay) force on critical=true
timeout) ⇒ failed)Every phase emits a typed event you can hook for logging or metrics.
API
new ShutdownManager(options?)
interface ShutdownManagerOptions {
signals?: NodeJS.Signals[]; // default ['SIGTERM','SIGINT']
timeout?: number; // total budget ms, default 30_000
developmentMode?: boolean; // default: NODE_ENV === 'development'
forceExit?: boolean; // default true
logger?: Logger; // default: silent noop
poll?: { intervalMs?: number }; // default 100
autoStart?: boolean; // default true
}Methods
| Method | Purpose |
| ---------------------------- | -------------------------------------------------------------- |
| .attach(server) | Track an http, https, http2, or http2.secure server |
| .detach(server) | Stop tracking a server |
| .register(name, fn, opts?) | Register a cleanup hook |
| .unregister(name) | Remove a previously registered hook |
| .kubernetes(opts) | Enable preStop delay + readiness flip |
| .isReady() | false once shutdown begins (wire to your /ready endpoint) |
| .isShuttingDown() | true while a shutdown is in progress |
| .phase() | Current LifecyclePhase |
| .trigger(reason?) | Manually run the shutdown lifecycle (returns ShutdownResult) |
| .start() / .stop() | Attach / detach signal handlers |
| .on(event, handler) | Typed event subscription |
Resource registry
shutdown.register(
'postgres',
async (signal) => {
// signal is an AbortSignal — honor it for cooperative cancellation
await pool.end();
},
{
timeout: 5_000, // per-resource budget; default 10_000
priority: 10, // lower runs first; default 0
concurrency: 'parallel', // 'parallel' (default) or 'sequential' within a priority group
critical: true, // a failure marks the whole shutdown as failed (exit 1)
},
);Events
shutdown.on('phase', ({ name, durationMs }) => …);
shutdown.on('connection:closed', ({ remaining, secure }) => …);
shutdown.on('resource:start', ({ name }) => …);
shutdown.on('resource:done', ({ name, durationMs }) => …);
shutdown.on('resource:error', ({ name, error, critical }) => …);
shutdown.on('timeout', ({ phase, elapsedMs }) => …);
shutdown.on('forced', ({ socketsDestroyed }) => …);
shutdown.on('error', (err) => …);Kubernetes
const shutdown = new ShutdownManager({ timeout: 15_000 })
.attach(server)
.kubernetes({ preStopDelayMs: 5_000 });What this does:
- Flips
isReady()tofalseimmediately on SIGTERM. - Waits
preStopDelayMsbefore callingserver.close()so the Service has time to mark the pod NotReady and stop routing new traffic. - Lets your
terminationGracePeriodSecondsbudget remain predictable (≈preStopDelayMs + timeout + 1–2s buffer).
See examples/kubernetes/ for the full Deployment YAML.
Docker
Set STOPSIGNAL SIGTERM in your Dockerfile (Node's default npm wrapper
sometimes swallows signals — running node directly avoids that). Use
tini or dumb-init as PID 1 if you need to reap zombies.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
STOPSIGNAL SIGTERM
CMD ["node", "dist/server.js"]Framework support
| Framework | Attach |
| --------- | ------------------------------------------------------------ |
| Express | manager.attach(app.listen(...)) |
| Koa | manager.attach(app.listen(...)) |
| Fastify | manager.attach(fastify.server) |
| NestJS | manager.attach(app.getHttpServer()) |
| Hono | manager.attach(serve({ fetch: app.fetch })) (Node adapter) |
| raw HTTP | manager.attach(http.createServer(...)) |
| HTTP/2 | manager.attach(http2.createSecureServer(...)) |
Testing
import { ShutdownManager, createTestHarness } from '@ambicuity/shutdown-manager';
const manager = new ShutdownManager({ signals: ['SIGUSR2'], forceExit: true });
const harness = createTestHarness({ manager });
await harness.sendSignal('SIGUSR2');
expect(harness.exitCode()).toBe(0);
expect(harness.timeline().map((p) => p.name)).toEqual([
'preShutdown',
'draining',
'cleanup',
'done',
]);The harness intercepts process.exit so the test process stays alive.
Comparison
| Feature | this | http-graceful-shutdown | terminus | lil-http-terminator | | --------------------------------------- | :--: | :--------------------: | :------: | :-----------------: | | TypeScript-first | ✅ | ❌ | ⚠️ | ⚠️ | | ESM + CJS dual build | ✅ | ❌ | ⚠️ | ✅ | | Zero runtime deps | ✅ | ❌ | ❌ | ✅ | | Resource registry (priority + parallel) | ✅ | ❌ | ✅ | ❌ | | Typed lifecycle events | ✅ | ⚠️ | ✅ | ❌ | | Kubernetes-aware (readiness + preStop) | ✅ | ❌ | ⚠️ | ❌ | | Testing harness | ✅ | ❌ | ❌ | ❌ | | npm provenance | ✅ | ❌ | ❌ | ❌ |
How to start using this in an existing codebase?
import { ShutdownManager } from '@ambicuity/shutdown-manager';
const manager = new ShutdownManager({ timeout: 10_000 }).attach(server);Zero runtime dependencies
This package has zero runtime dependencies. It uses only the Node.js standard library. Releases are published with --provenance and signed via GitHub Actions OIDC.
Support
If this package saves you a 3am incident, consider buying me a coffee: buymeacoffee.com/ritesh.rana.
For commercial support or custom integration questions, email
[email protected].
License
MIT © Ritesh Rana ([email protected])
