Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you made request failures reach one Express error handler. This final lesson in the module looks at a different boundary: the lifetime of the whole service. A request may be handled correctly and still fail if the process stops halfway through it or closes its database connection too soon.
You’ll build an Express service that verifies an external connection before becoming ready, stops accepting work when asked to shut down, lets in-flight HTTP requests finish, and then closes that connection. Allow about 40 minutes, including the implementation and a signal test.
Readiness is a promise to accept work
An open port does not necessarily mean an application can serve requests. If a route depends on a database, the service should verify that dependency before advertising itself as ready. At shutdown, it should withdraw that promise before closing the HTTP listener or the database connection.
It helps to name four states:
| State | Meaning | GET /ready |
|---|---|---|
starting |
Critical dependencies are being established | Unavailable |
ready |
Normal traffic can be handled | 200 |
draining |
Existing work may finish; new work should not start | 503 |
stopped |
Listener and owned connections are closed | No response |
GET /live answers a different question: is the process still functioning? During a planned drain, it can return 200 while /ready returns 503. In this lesson’s implementation, the listener does not open until startup succeeds, so neither endpoint is reachable during starting.
The supplied timeline illustrates why becoming unready is not enough on its own. A traffic router may take time to notice a failed readiness check; requests can still arrive in that interval.

A draining service should therefore also reject new normal requests that reach it during that interval. An already-running handler, however, needs continued access to its dependencies.
What closing the HTTP server actually does
In How to do Graceful shutdown of a Nodejs server, Mafia Codes demonstrates an active request completing after the server receives an interruption. Watch the server-close demonstration and the subsequent explanation of why an external connection must stay open until that request finishes. The demonstration uses MongoDB; our implementation will use PostgreSQL, but the ownership rule is the same.
How to do Graceful shutdown of a Nodejs server
Watch How to do Graceful shutdown of a Nodejs server from Mafia Codes to see the difference between stopping a listener and interrupting work already underway.
In closing HTTP, notice that the server's close callback waits for active requests. In active request, watch a request finish after the interruption. Then watch connection cleanup for the reason the database connection closes afterward. We will not copy the video's use of immediate process exit: normally, closing owned handles lets Node exit naturally.
The precise behavior matters. server.close() stops accepting new connections and allows active HTTP requests to finish. On modern Node.js versions, it also closes idle connections. It does not put a time limit on a request that never finishes. server.closeAllConnections() can terminate active ordinary HTTP connections, so it belongs at a deadline, not at the beginning of a normal drain.
Read those distinctions in the official Node.js HTTP documentation:
HTTP | Node.js v25.4.0 Documentation
Read the Node.js HTTP documentation for the three server methods used in the shutdown policy.
Find the server.close([callback]) subsection and read close semantics. Then find server.closeIdleConnections(); read the version note. Finally, under server.closeAllConnections(), read the force-close behavior and the caution immediately below it. Focus on which methods preserve active requests and which can interrupt them.
One limitation to retain: these ordinary HTTP connection methods do not manage upgraded connections such as WebSockets. Those need their own tracking and close policy when you add real-time communication later in the course.
Implement the lifecycle around Express
Use the Express 5 and TypeScript project from the previous lesson. This example uses a PostgreSQL pool as the owned external connection; it does not require you to learn database schema design yet. Install its client:
npm install pg
npm install -D @types/pg
Extend the startup validation in your existing src/config.ts so its typed config object includes a required databaseUrl, read from DATABASE_URL. Keep the existing validated config.port. A missing database URL should fail startup rather than silently produce a service whose routes cannot work. To run the example, you will need a reachable PostgreSQL instance.
Create src/graceful-demo.ts:
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import express, { type ErrorRequestHandler } from "express";
import { Pool } from "pg";
import { config } from "./config.js";
type State = "starting" | "ready" | "draining" | "stopped";
const pool = new Pool({
connectionString: config.databaseUrl,
connectionTimeoutMillis: 2_000,
query_timeout: 2_000,
});
const app = express();
let state: State = "starting";
app.get("/live", (_request, response) => {
response.status(200).json({ status: "live" });
});
app.get("/ready", (_request, response) => {
response.status(state === "ready" ? 200 : 503).json({ state });
});
// Register this gate before normal application routes.
app.use((_request, response, next) => {
if (state !== "ready") {
response.set("Connection", "close");
response.status(503).json({ error: "Service unavailable" });
return;
}
next();
});
// A slow request makes the shutdown order visible during a local test.
app.get("/demo/slow", async (_request, response) => {
await delay(3_000);
await pool.query("SELECT 1");
response.json({ ok: true });
});
const errorHandler: ErrorRequestHandler = (
error: unknown,
_request,
response,
next,
) => {
console.error("Request failed", error);
if (response.headersSent) {
next(error);
return;
}
response.status(500).json({ error: "Internal server error" });
};
app.use(errorHandler);
const server = createServer(app);
let listening = false;
let shutdownPromise: Promise<void> | undefined;
async function start(): Promise<void> {
// Do not advertise readiness until this critical dependency works.
await pool.query("SELECT 1");
// A signal could have arrived while the database check was pending.
if (state !== "starting") return;
await new Promise<void>((resolve, reject) => {
function onError(error: Error): void {
server.off("error", onError);
reject(error);
}
server.once("error", onError);
server.listen(config.port, () => {
server.off("error", onError);
listening = true;
resolve();
});
});
// A signal could also have arrived while listen was pending.
if (state === "starting") {
state = "ready";
console.log(`Ready on port ${config.port}; PID ${process.pid}`);
}
}
function requestShutdown(reason: string): Promise<void> {
if (shutdownPromise !== undefined) return shutdownPromise;
state = "draining";
console.log(`Draining: ${reason}`);
shutdownPromise = stop();
return shutdownPromise;
}
async function stop(): Promise<void> {
// This bounds the entire attempt, including startup and pool cleanup.
const deadline = setTimeout(() => {
console.error("Shutdown deadline exceeded; forcing exit");
server.closeAllConnections();
process.exit(1);
}, 10_000);
try {
// If termination arrived during startup, settle that attempt first.
await startPromise.catch(() => undefined);
if (listening) {
// Demo allowance for a traffic router to observe failed readiness.
await delay(1_000);
await new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) reject(error);
else resolve();
});
server.closeIdleConnections();
});
}
// No active HTTP handler should still need the pool now.
await pool.end();
state = "stopped";
clearTimeout(deadline);
console.log("Shutdown complete");
} catch (error: unknown) {
process.exitCode = 1;
console.error("Shutdown failed", error);
// Keep the deadline armed if cleanup left a handle open.
}
}
process.on("SIGTERM", () => {
void requestShutdown("SIGTERM");
});
process.on("SIGINT", () => {
void requestShutdown("SIGINT");
});
const startPromise = start();
void startPromise.catch((error: unknown) => {
process.exitCode = 1;
console.error("Startup failed", error);
void requestShutdown("startup failure");
});
There are three ordering decisions worth tracing through the code.
First, the initial database query completes before the listener opens. If it fails, startup logs the failure, sets a nonzero exit code, and requests cleanup; the service never becomes ready. The checks around state also prevent a late startup completion from marking the service ready after a termination signal.
Second, requestShutdown() sets draining immediately. During the one-second demonstration allowance, /ready returns 503, /live still returns 200, and the middleware gate rejects newly arriving normal requests. This interval represents time for routing changes to propagate; one second is not a universal production setting. The saved shutdownPromise ensures repeated signals do not start competing close operations.
Third, server.close() waits for active HTTP work before pool.end() runs. The slow route deliberately accesses the pool after its delay: closing the pool as soon as SIGTERM arrives would break a request that had already been accepted. The ten-second deadline is a last resort, not a promise that every request will succeed. If it expires, forced exit can interrupt work. In a deployment, choose a budget shorter than the platform’s own termination grace period. Unlike SIGTERM and SIGINT, SIGKILL cannot be handled by this code.
Observe a real drain
Build and start the compiled service with the environment variables required by your configuration. For example, supply a working connection string as DATABASE_URL alongside your existing required variables:
npm run build
DATABASE_URL="postgresql://..." GREETING=Welcome PORT=4100 \
node dist/graceful-demo.js
In another terminal, confirm that /ready returns 200. Start a slow request, then, before its three-second delay ends, send SIGTERM to the PID printed by the server:
curl -i http://localhost:4100/ready
curl -i http://localhost:4100/demo/slow
# In another terminal while that request is pending:
kill -TERM <printed-pid>
During the brief propagation allowance, a check of /ready should return 503; a new /demo/slow request should also receive 503 rather than begin three seconds of work. The request started before SIGTERM should still return {"ok":true}. Only afterward should the server log Shutdown complete and exit. The one-second window is short, so the response and log ordering are more dependable observations than manually timing a readiness check.
You can also test startup failure by supplying an unreachable database address. You should see Startup failed and no Ready on port ... message. This is preferable to accepting traffic that will predictably fail.
Takeaway
A graceful service lifecycle is an ordered, bounded attempt: verify critical connections before becoming ready; on termination, become unready and reject new work; let accepted HTTP requests finish; then close the connections those requests relied on. Repeated signals should trigger that sequence only once, and a deadline must make stalled shutdown visible rather than leaving the process running indefinitely.
This completes the production Node.js foundations module. The next module moves from keeping the service available to designing the REST endpoints it serves: resources, HTTP methods, status codes, and response headers.