Every deploy restarts your Node.js app. So do configuration changes, server maintenance and process manager restarts. If the app simply dies when it is told to stop, any request in progress at that moment fails: a checkout that never completes, a form submission that returns an error, a background job that stops halfway through. Graceful shutdown means the app stops accepting new work, finishes what it's doing, closes its connections cleanly and then exits. It takes about thirty lines of code and removes a whole class of "random" errors that show up around deploys.
How a Node.js process is told to stop
Process managers and container platforms don't pull the plug immediately. They send a signal first, wait a short time, and only then force the process to end:
| Signal | Sent by | Can your code handle it? |
|---|---|---|
SIGTERM |
Most process managers, Docker, Kubernetes, systemd | Yes |
SIGINT |
Ctrl+C in a terminal, and some process managers | Yes |
SIGKILL |
Sent after the grace period runs out | No: the process ends at once |
Your job is to finish cleanly within the grace period. Handle both SIGTERM and SIGINT, because different tools send different ones, and check how long your process manager waits before it sends SIGKILL.
The basic pattern for an HTTP server
import http from "node:http";
import { app } from "./app.js"; // your Express, Fastify or plain handler
import { pool } from "./db.js"; // e.g. a pg or mysql2 pool
const server = http.createServer(app);
server.listen(process.env.PORT || 3000);
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received: closing server`);
// Force exit if cleanup takes too long, so a hung connection can't block the restart.
const timer = setTimeout(() => {
console.error("Shutdown timed out, exiting");
process.exit(1);
}, 10_000);
timer.unref();
// 1. Stop accepting new connections and wait for in-flight requests to finish.
server.close(async (err) => {
// 2. Close database pools, queues and other clients.
await pool.end().catch(() => {});
process.exit(err ? 1 : 0);
});
// 3. Close idle keep-alive connections so close() isn't left waiting on them.
server.closeIdleConnections();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
What each step does:
server.close()stops the server accepting new connections. Its callback runs once every existing connection has closed.- Closing the database pool lets running queries finish and releases connections on the database server, so the new process isn't competing with leftovers. See our PostgreSQL connection guide for the pool setup.
closeIdleConnections()(Node.js 18.2 and later) closes keep-alive connections that aren't serving a request. Without it, browsers holding idle keep-alive connections can keepclose()waiting.- The timeout guarantees the process exits even if something hangs. Keep it shorter than your process manager's grace period.
Express and Fastify
With Express, app.listen() returns the same http.Server, so the pattern works unchanged. Fastify has fastify.close(), which stops the server and runs your onClose hooks, so put pool cleanup in an onClose hook.
Tell the load balancer first
If your app runs as more than one instance behind a load balancer, stop new traffic from arriving before closing the server. A common approach is a readiness endpoint that starts failing during shutdown:
app.get("/health/ready", (req, res) => {
res.status(shuttingDown ? 503 : 200).send(shuttingDown ? "shutting down" : "ok");
});
The load balancer sees the 503, stops routing requests to this instance, and the in-flight requests finish. Health checks are covered in more detail in our Express API hosting guide.
Background work and scheduled jobs
HTTP requests are only part of the picture. Before exiting, an app should also:
- Stop taking new jobs from a queue, and let the current job finish or put it back.
- Clear intervals and timers started with
setInterval, so they don't start new work. - Flush logs and metrics that are buffered in memory.
Long jobs are the hardest part. Design them so they can be stopped and run again safely (idempotent), for example by processing records in small batches and recording progress. Then a restart in the middle costs a few seconds, not a corrupted import. If the work runs on a schedule, consider triggering it through an HTTP endpoint from a scheduler instead of a timer inside the app: see cron jobs in Node.js.
Next.js and other frameworks
next start and the standalone server.js handle SIGTERM and SIGINT themselves and shut down the server for you, but they don't know about your own clients, such as database pools created in your code. Database connections are closed when the process exits, which databases handle, but explicit cleanup is gentler on connection limits during frequent deploys. Our Next.js production checklist covers other settings to check before launch.
Testing it
You can test graceful shutdown locally:
- Start the app and send it a slow request, for example an endpoint that waits five seconds before answering.
- While that request is running, send the process a
SIGTERM(kill -TERM <pid>on Linux or macOS) or press Ctrl+C. - The slow request should complete successfully, new requests should be refused, and the process should exit on its own with the log message you added.
If the slow request fails, the app is exiting too early. If the process never exits, something is still holding the event loop open: an open connection, an interval or a client that wasn't closed.
Frequently asked questions
Why does my app take exactly the timeout to stop?
Something is keeping the event loop alive: usually keep-alive connections, an open database pool or a setInterval. Close them in the shutdown handler.
Should I call process.exit() at all?
Ideally the process exits on its own once everything is closed. In practice an explicit exit after cleanup, plus a forced exit after a timeout, is more predictable.
Does graceful shutdown give me zero-downtime deploys?
It's one part. You also need the new version to start and be ready before the old one stops taking traffic, which depends on how your platform deploys.
Do I need this for a small site?
It costs little and prevents errors on every deploy, so yes, it's worth adding to any app that handles forms, payments or background work.
NewHost Node.js hosting restarts your app on every deploy from GitHub, with live build logs and one-click rollbacks, on South African servers. Running Next.js? See Next.js hosting.