Skip to content

Graceful Shutdown

You need to manage the full production lifecycle of an HTTP server — connecting to databases before serving requests, draining in-flight requests on shutdown signals, and cleaning up resources (database pools, OpenTelemetry SDK, background jobs) before the process exits.

ergo-router’s graceful() utility manages the complete server lifecycle: startup prerequisites, signal handling, connection draining, and orderly shutdown.

import { handler } from "@centralping/ergo";
import { graceful } from "@centralping/ergo-router";
const pipeline = handler(/* ... your pipeline ... */);
const { server, shutdown } = await graceful(pipeline, {
port: 3000,
onStartup: async ({ log }) => {
await db.connect();
log.info("Database connected");
},
onShutdown: async ({ log, signal }) => {
await db.disconnect();
log.info(`Database disconnected (${signal})`);
},
});
import { NodeSDK } from "@opentelemetry/sdk-node";
import { graceful } from "@centralping/ergo-router";
const sdk = new NodeSDK({ /* ...config... */ });
const { server, shutdown } = await graceful(pipeline, {
port: 3000,
onStartup: async ({ log }) => {
sdk.start();
log.info("OpenTelemetry SDK started");
},
onShutdown: async ({ log }) => {
await sdk.shutdown();
log.info("OpenTelemetry SDK flushed and shut down");
},
});
import { graceful } from "@centralping/ergo-router";
let isShuttingDown = false;
const { server, shutdown } = await graceful(pipeline, {
port: 3000,
onShutdown: async ({ log }) => {
isShuttingDown = true;
await db.disconnect();
},
});
// Expose shutdown state in your health check route
// check the closure variable `isShuttingDown` in your handler
function healthCheck() {
if (isShuttingDown) {
return { response: { statusCode: 503, body: { status: "draining" } } };
}
return { response: { body: { status: "healthy" } } };
}
onStartup({log})
server.listen(port, hostname)
◀── serving requests ──▶
│ (SIGINT or SIGTERM received)
server.close() ← stops accepting new connections
onShutdown({log, signal})
waiting for in-flight connections to drain...
├── connections drained → "Shutdown complete" → exit(0)
└── timeout exceeded → server.closeAllConnections() → exit(1)
OptionDefaultDescription
port3000Port to listen on
hostname'0.0.0.0'Hostname to bind to
logconsoleLogger with .info(), .warn(), .error() methods
signals['SIGINT', 'SIGTERM']OS signals that trigger shutdown
timeout5000Maximum time (ms) to wait for connections to drain
exitprocess.exitExit function (override for testing)
onStartupHook called before server.listen()
onShutdownHook called after server.close()

If onStartup rejects (throws), the server never starts listening. Use this to enforce prerequisites — database connectivity, config validation, secret loading — before accepting traffic.

The timeout option provides a hard deadline for graceful shutdown. If in-flight connections do not close within the timeout window, the server forcefully terminates all connections and exits with code 1. Set the timeout to match your load balancer’s drain timeout.

graceful() returns { server, shutdown }. Call shutdown(signal) programmatically to initiate the same graceful shutdown flow without waiting for an OS signal. This is useful for health-check-driven shutdown or integration tests.

For test-specific lifecycle patterns (ephemeral ports, preventing process.exit), see Testing Patterns.

The log option accepts any object with .info(), .warn(), and .error() methods — a pino or winston instance works directly. For complete integration patterns including request ID correlation and trace enrichment, see Structured Logging.

Chain cleanup operations in onShutdown. Errors in onShutdown are caught and logged — they do not prevent the shutdown from completing:

onShutdown: async ({ log, signal }) => {
await db.disconnect();
await cache.quit();
await sdk.shutdown();
log.info(`All resources released (${signal})`);
}