graceful-shutdown-sri
v1.0.0
Published
Production-ready, framework-agnostic graceful shutdown manager for Node.js: drains HTTP servers and safely releases databases, queues, caches and custom resources on SIGTERM/SIGINT and fatal errors.
Maintainers
Readme
graceful-shutdown-sri
A production-ready, framework-agnostic graceful shutdown manager for Node.js.
When your process receives SIGTERM/SIGINT or crashes with an uncaughtException or unhandledRejection, graceful-shutdown-sri stops accepting traffic, lets in-flight requests finish, and closes your WebSockets, queues, caches and databases in the right order within a time budget. Then it exits with a meaningful exit code.
- Zero runtime dependencies. Adapters use structural types, so they don't import the libraries they close.
- Fully typed TypeScript API, with ESM and CommonJS builds. Tree-shakeable (
sideEffects: false). - Node.js 18+.
- Correct HTTP draining: idle keep-alive sockets are closed, in-flight requests finish and get
Connection: close, and leftovers are force-destroyed on timeout. - Ordering: built-in priority tiers plus
dependsOn, runsequentialorparallel. - Timeouts per resource and globally. Resources get an
AbortSignalso they can switch to a forced close. - Observability: typed lifecycle events and a detailed shutdown report.
- Leak-safe: every listener it attaches can be removed (
unlisten(),dispose(), unregister functions), and its timers never keep the process alive.
Built-in adapters: HTTP, HTTPS, Express, Fastify, Socket.IO, ws, Redis (ioredis / node-redis), Sequelize, TypeORM, Prisma, BullMQ, RabbitMQ (amqplib), KafkaJS, MongoDB / Mongoose, PostgreSQL (pg / postgres.js), MySQL (mysql2 / mysql), plus any custom task.
Installation
npm install graceful-shutdown-sriQuick start
import http from 'node:http';
import { createShutdownManager } from 'graceful-shutdown-sri';
const server = http.createServer(app);
const shutdown = createShutdownManager({
timeout: 15_000,
logger: console,
});
shutdown.registerHttpServer(server);
shutdown.registerTask(async () => {
await redis.disconnect();
});
shutdown.registerTask(async () => {
await sequelize.close();
});
shutdown.registerTask(async () => {
await queue.close();
});
shutdown.listen();
server.listen(3000);Or use the adapters, which know each library's safest close sequence and put it in the right priority tier:
import {
createShutdownManager,
redisResource,
sequelizeResource,
bullMqResource,
} from 'graceful-shutdown-sri';
const shutdown = createShutdownManager({ timeout: 15_000 });
shutdown.registerHttpServer(server); // stopped first
shutdown.register(bullMqResource([worker, queue])); // then queue workers
shutdown.register(redisResource(redis)); // databases and caches last
shutdown.register(sequelizeResource(sequelize));
shutdown.listen();On SIGTERM this logs something like:
[graceful-shutdown-sri] Received SIGTERM
[graceful-shutdown-sri] Shutdown started (signal SIGTERM), 4 resource(s), timeout 15000ms
[graceful-shutdown-sri] Closed "http-server" in 38ms
[graceful-shutdown-sri] Closed "bullmq" in 412ms
[graceful-shutdown-sri] Closed "redis" in 3ms
[graceful-shutdown-sri] Closed "sequelize" in 5ms
[graceful-shutdown-sri] Shutdown complete in 460msThen the process exits with code 0.
Frameworks
Express
app.listen() returns an http.Server, so register that:
const app = express();
const server = app.listen(3000);
shutdown.registerHttpServer(server);Fastify
import { fastifyResource } from 'graceful-shutdown-sri';
const app = Fastify();
shutdown.register(fastifyResource(app));
await app.listen({ port: 3000 });HTTPS
import https from 'node:https';
const server = https.createServer({ key, cert }, app);
shutdown.registerHttpServer(server); // TLS is detected automaticallySocket.IO and ws
Realtime servers are in the highest tier, so clients are told to disconnect before the HTTP server stops:
import { socketIoResource, webSocketServerResource } from 'graceful-shutdown-sri';
shutdown.register(socketIoResource(io));
shutdown.register(webSocketServerResource(wss)); // sends close code 1001, terminates on timeout
shutdown.registerHttpServer(server);Resource adapters
| Adapter | Accepts | Close behaviour | Default tier |
| ------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------- | ------------ |
| httpServerResource / registerHttpServer | http.Server, https.Server, Express app.listen() | Stop accepting, close idle sockets, drain in-flight, force on timeout | SERVER |
| httpsServerResource | https.Server | Same, TLS-aware | SERVER |
| fastifyResource | Fastify instance | app.close() | SERVER |
| socketIoResource | Socket.IO Server | disconnectSockets(true) + close() | REALTIME |
| webSocketServerResource | ws WebSocketServer | Close frame to clients, wait, terminate() on timeout | REALTIME |
| bullMqResource | Worker, Queue, QueueEvents, FlowProducer (one or an array) | close() in order, close(true) on timeout | MESSAGING |
| rabbitMqResource | amqplib connection, or { connection, channels } | Close channels, then the connection | MESSAGING |
| kafkaResource | KafkaJS consumer / producer / admin (one or an array) | disconnect() in order | MESSAGING |
| redisResource | ioredis, node-redis v4/v5 | quit() (or close()), disconnect() on timeout | DATABASE |
| sequelizeResource | Sequelize | close() | DATABASE |
| typeOrmResource | DataSource (0.3) / Connection (0.2) | destroy() / close(), skipped if not initialized | DATABASE |
| prismaResource | PrismaClient | $disconnect() | DATABASE |
| mongoDbResource | MongoClient, Mongoose connection / mongoose | close(), close(true) on timeout (or disconnect()) | DATABASE |
| postgresResource | pg Pool / Client, postgres.js sql | end() | DATABASE |
| mySqlResource | mysql2 pool/connection (callback or promise API), mysql | end() | DATABASE |
Every adapter accepts { name, priority, dependsOn, timeout } as its last argument. See docs/resources.md for notes on each one.
Shutdown order
By default, resources are stopped in priority tiers, highest first:
| Tier | Value | Meant for |
| -------------------- | ----- | --------------------------------------------------------------------------- |
| Priority.REALTIME | 400 | Socket.IO, ws: tell clients to go away before the server stops |
| Priority.SERVER | 300 | HTTP/HTTPS/Fastify: stop accepting traffic and drain requests |
| Priority.MESSAGING | 200 | Queue workers, consumers, producers |
| Priority.DEFAULT | 100 | Custom registerTask tasks |
| Priority.DATABASE | 0 | Databases and caches: everything above may still need them, so they go last |
Within a tier, resources run in registration order.
Dependencies override priorities. dependsOn lists resources that this resource uses. A resource is always stopped before the resources it depends on:
shutdown.register(redisResource(redis, { name: 'cache' }));
shutdown.register(prismaResource(prisma, { name: 'db' }));
// The outbox flusher writes to the DB and reads the cache, so it must run before both close.
shutdown.registerTask(flushOutbox, { name: 'outbox', priority: 0, dependsOn: ['db', 'cache'] });Cycles are rejected with a DependencyCycleError at registration time. Unknown names produce a warning at shutdown and are ignored.
Execution mode. sequential (the default) stops one resource at a time. parallel stops all resources of the same phase concurrently. A phase is a set of resources with the same priority whose dependents have already stopped, so ordering guarantees hold in both modes. shutdown.getExecutionPlan() returns the phases, which is useful for checking your setup:
console.log(shutdown.getExecutionPlan());
// [ ['socket.io'], ['http-server'], ['bullmq'], ['outbox'], ['cache', 'db'] ]Options
interface ShutdownOptions {
timeout?: number; // 30000. Global time budget in ms (0 = none)
logger?: Logger | false; // console. Anything with info/warn/error(/debug). false = silent
exitProcess?: boolean; // true. Call process.exit() when done
closeIdleConnections?: boolean; // true. HTTP: drop idle keep-alive sockets immediately
closeAllConnections?: boolean; // false. HTTP: drop every socket immediately
forceExitOnTimeout?: boolean; // true. On global timeout, exit(1) right away
executionMode?: 'sequential' | 'parallel'; // 'sequential'
debug?: boolean; // false. Verbose logs
signals?: NodeJS.Signals[]; // ['SIGTERM', 'SIGINT']
handleUncaughtException?: boolean; // true
handleUnhandledRejection?: boolean; // true
forceExitOnSecondSignal?: boolean; // true. A second Ctrl+C exits immediately
preShutdownDelay?: number; // 0. Wait before closing (Kubernetes endpoint propagation)
}Exit codes: 0 when every resource closed successfully. 1 when any resource failed or timed out, when the global timeout elapsed, or when the shutdown was caused by uncaughtException/unhandledRejection.
With forceExitOnTimeout: false, a global timeout sets process.exitCode = 1 and removes the signal handlers instead of calling process.exit(), so the process ends once the event loop drains.
API
const shutdown = createShutdownManager(options);
shutdown.register(resource); // → unregister()
shutdown.registerTask(fn, { name, priority, dependsOn, timeout }); // → unregister()
shutdown.registerHttpServer(server, { closeIdleConnections, closeAllConnections, ...}); // → unregister()
shutdown.listen(); // attach signal/error handlers (idempotent)
shutdown.unlisten(); // detach them
await shutdown.shutdown('reason'); // trigger manually → ShutdownReport (idempotent)
shutdown.isShuttingDown(); // for readiness probes
shutdown.state; // 'idle' | 'shutting-down' | 'terminated'
shutdown.getReport(); // last ShutdownReport
shutdown.getExecutionPlan(); // string[][]
shutdown.on(event, listener); // → off()
shutdown.once(event, listener);
shutdown.off(event, listener);
shutdown.dispose(); // unlisten + release every resource and listenerFull reference: docs/api.md.
Custom resources
Anything with a close() works. The context gives you the reason, an AbortSignal that fires when your time is up, and the logger:
shutdown.register({
name: 'metrics-flusher',
priority: Priority.DEFAULT,
dependsOn: ['db'],
timeout: 5_000,
async close({ signal, reason, logger }) {
logger.info(`flushing metrics (${reason.type})`);
await metrics.flush({ signal }); // stop early if we run out of time
},
});To write a reusable adapter, return a ShutdownResource. If your adapter attaches listeners at registration time, implement dispose() too, so unregistering releases them.
Events and reports
shutdown.on('shutdown:start', ({ reason }) =>
metrics.increment('shutdown', { reason: reason.type }),
);
shutdown.on('resource:error', ({ name, error }) =>
sentry.captureException(error, { tags: { name } }),
);
shutdown.on('resource:timeout', ({ name }) => logger.warn(`${name} was too slow`));
shutdown.on('shutdown:complete', (report) => logger.info(report, 'shutdown report'));| Event | Payload |
| ------------------- | ---------------------------------------- |
| shutdown:start | { reason } |
| resource:start | { name } |
| resource:success | ResourceResult |
| resource:error | ResourceResult (with error) |
| resource:timeout | ResourceResult |
| shutdown:timeout | { timeoutMs } |
| shutdown:complete | ShutdownReport, emitted before exiting |
A ShutdownReport contains reason, startedAt, finishedAt, durationMs, success, timedOut, exitCode and one { name, status, durationMs, error? } entry per resource. status is one of success, failed, timeout or skipped. A throwing listener is logged and never breaks the shutdown.
Kubernetes
const shutdown = createShutdownManager({
timeout: 25_000, // below terminationGracePeriodSeconds (default 30s)
preShutdownDelay: 5_000, // let endpoints/load balancers stop routing to this pod
});
app.get('/readyz', (_req, res) => {
res.status(shutdown.isShuttingDown() ? 503 : 200).end();
});See docs/guides.md for details, and for Docker CMD advice (PID 1 must receive the signal).
Error handling
- A resource that throws or rejects is recorded as
failed. The rest still run. - A resource that exceeds its
timeoutis recorded astimeoutand itsAbortSignalfires. The rest still run. - When the global
timeoutelapses, every running resource is aborted, the pending ones areskipped, and the process exits with1. - An
uncaughtExceptionorunhandledRejectionis logged and triggers a shutdown with exit code1. Errors raised during shutdown are logged, not re-thrown. - A second signal during shutdown forces an immediate exit (
forceExitOnSecondSignal). - A logger or event listener that throws can never interrupt the shutdown.
With
exitProcess: false, the handlers still catchuncaughtException, so the process keeps running after a crash. Only disableexitProcessif something else is responsible for exiting.
Testing your app
const shutdown = createShutdownManager({ exitProcess: false, logger: false });
// ...register resources...
const report = await shutdown.shutdown('test');
expect(report.success).toBe(true);
shutdown.dispose();Examples
See examples/: Express, Fastify, a full stack (HTTP + Socket.IO + BullMQ + Redis + Prisma), Kubernetes, and custom resources.
Contributing
npm ci
npm test # vitest
npm run test:coverage
npm run lint && npm run typecheck && npm run format:check
npm run build && npm run smokeCommits follow Conventional Commits. Releases are published by semantic-release from main.
