Graceful Shutdown
Catch SIGTERM, stop accepting connections, finish the requests in flight, and exit before the platform sends SIGKILL.
process.on('SIGTERM', () => server.close(() => { process.exitCode = 0; }))A signal is a short message the operating system delivers to a running process. docker stop sends SIGTERM ("please stop"), waits ten seconds by default, then sends SIGKILL, which cannot be caught and ends the process immediately. Graceful shutdown is the work you do in that window: finish the requests already in flight instead of dropping them.
The sequence
On SIGTERM, call server.close(callback). It stops accepting new connections, lets in-flight requests finish, and calls the callback when the last connection is gone. Since Node 19.0.0 it also closes idle keep-alive connections, so server.closeIdleConnections() is no longer needed alongside it. server.closeAllConnections() (Node 18.2.0) cuts even active requests, which is a last resort. Then close your database pool, then stop. Set process.exitCode and let the process end on its own rather than calling process.exit(), which abandons pending work at once.
Your listener replaces Node's
On non-Windows platforms Node installs default handlers for SIGTERM and SIGINT that exit with code 128 + signal number. Adding your own listener removes that default. Exiting becomes entirely your job, so pair the handler with a timer that forces the issue.
PID 1
The first process in a container is PID 1, and the kernel applies no default signal action to it. The Node project's own guidance is that Node was not designed to run as PID 1: run docker run --init, or put a small init program such as dumb-init in front of node. A shell-form CMD node server.js is worse, because /bin/sh becomes PID 1 and never passes the signal on.
Example
When to use it
- Deploy a new version without returning errors to requests that were already being served.
- Close a database pool cleanly so the server does not accumulate abandoned connections.
- Explain why containers take exactly ten seconds to stop and then report exit code 137.
- Flip a health endpoint to 503 before draining so no new traffic arrives mid-shutdown.
More examples
Shutdown with a pool, a health flag and a deadline
The production shape. Eight seconds is chosen to be inside Docker's ten-second default, so the process decides its own exit code instead of being killed. process.once rather than process.on means a repeated Ctrl-C cannot re-enter the handler.
let healthy = true;
// The health endpoint answers 503 the moment shutdown starts, so a load
// balancer stops sending new work while the old requests drain.
app.get('/health', (req, res) => res.status(healthy ? 200 : 503).end());
async function shutdown(signal) {
if (!healthy) return;
healthy = false;
console.log(JSON.stringify({ msg: 'shutdown started', signal }));
// If draining hangs, exit anyway - before SIGKILL arrives.
// unref() keeps this timer from holding the process open on the happy path.
const deadline = setTimeout(() => {
console.log(JSON.stringify({ msg: 'shutdown timed out' }));
process.exit(1);
}, 8000);
deadline.unref();
await new Promise((resolve) => server.close(resolve));
await pool.end(); // real call: closes every pooled database connection
clearTimeout(deadline);
process.exitCode = 0;
}
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => shutdown(signal));
}Making sure node receives the signal
Three commands that look equivalent and are not. Check which one you have with docker stop: a graceful container logs its shutdown lines and exits in under a second, while a broken one sits there for the full ten-second grace period.
# Exec form: node itself is the container's main process.
CMD ["node", "server.js"]
# Shell form - the same words, a different result. /bin/sh becomes PID 1
# and does not forward SIGTERM, so shutdown never runs:
# CMD node server.js
# Also never this in production: npm becomes PID 1 and node is its child.
# CMD ["npm", "start"]
# An init program handles PID 1 duties and passes signals through.
# RUN apk add --no-cache dumb-init
# CMD ["dumb-init", "node", "server.js"]Proving it works
Exit code 137 is the signature of a shutdown that did not finish in time, and it is the fastest way to tell a missing handler from a slow one. Run this check once per service; it is cheap and it catches the shell-form CMD mistake immediately.
docker run -d --name api --init -p 3000:3000 orders-api:1.0.0
# Send SIGTERM and time how long the container takes to exit.
time docker stop api # ten-second grace period by default
# Shorten or lengthen that window:
docker stop -t 30 api
# 0 means the process exited by itself. 137 is 128 + 9: it was SIGKILLed,
# which means the grace period ran out.
docker inspect api --format '{{.State.ExitCode}}'
docker logs api # the shutdown lines should be here
Discussion