Quick Tip
Node’s default SIGTERM handler exits immediately, so every deploy kills your in-flight requests. Here’s a shutdown handler that drains them properly, in about 80 lines.
The Problem
Requests die mid-response and nothing records it
The Problem
By default, a Node process receiving SIGTERM exits. Right then. Any request currently being handled is cut off mid-response, any transaction in progress is abandoned, and any message pulled off a queue but not yet acknowledged goes back on it or vanishes depending on your broker.
The insidious part is the observability gap. Your process is gone before it can log an error, so your error rate looks clean. The client sees ECONNRESET, which your APM records as a network blip if it records it at all. You get a small, unexplained error spike during every deploy that nobody ever tracks down because it never points at code.
There’s a second problem hiding behind the first. Kubernetes sends SIGTERM and starts removing your pod from the Service endpoints at the same moment, and it does not wait for one to finish before doing the other. Endpoint removal has to propagate to kube-proxy on every node, which takes anywhere from a few hundred milliseconds to several seconds on a large cluster. During that window new connections are still being routed to you.
What this costs in practice
On a service doing 500 requests per second with 6 pods, a rolling deploy replaces one pod at a time. Each replacement drops whatever that pod was handling. That’s a few dozen failed requests per deploy, and if you deploy 20 times a day it’s hundreds of user-visible failures a day that never appear on a dashboard.
The worse version involves data. A payment handler killed between charging a card and writing the record leaves you with a charge and no order. A queue consumer killed after processing but before acknowledging causes the message to be redelivered, and if the handler isn’t idempotent it happens twice.
The Solution
Refuse new work, finish existing work, then leave
The Fix
Handle the signal yourself and run an ordered shutdown: stop telling the load balancer you’re ready, wait for it to notice, stop accepting new connections while continuing to serve open ones, then close your outbound resources and exit.
Why the sleep isn’t a hack
It looks like one. It isn’t. The readiness probe failing and the endpoint list updating are two different events separated by real time, and there’s no callback that tells you the second has finished. Sleeping is how you wait for a distributed system to converge on something it has already decided.
Five seconds is a reasonable default. If your readiness probe runs every 10 seconds you need at least that, plus propagation, so 15 is safer on a big cluster.
Script Implementation
Setup and configuration
import type {Server} from 'node:http';
interface ShutdownOptions { /** How long to keep serving after SIGTERM, waiting for endpoint * removal to reach kube-proxy on every node. Not optional. */ drainDelayMs: number; /** Hard cap on waiting for in-flight requests. Must leave headroom * inside terminationGracePeriodSeconds. */ shutdownTimeoutMs: number;}
const options: ShutdownOptions = { drainDelayMs: Number(process.env.DRAIN_DELAY_MS ?? 5_000), shutdownTimeoutMs: Number(process.env.SHUTDOWN_TIMEOUT_MS ?? 15_000),};
// The readiness probe reads this. Flipping it to false is the first// thing shutdown does, and it is what takes us out of rotation.let isShuttingDown = false;
export const isReady = () => !isShuttingDown;The cleanup registry
type Cleanup = {name: string; close: () => Promise<void>};
const cleanups: Cleanup[] = [];
/** Register anything holding a socket or a file handle. Order matters: * cleanups run in reverse registration order, so register the database * before the things that depend on it. */export function onShutdown(name: string, close: () => Promise<void>): void { cleanups.push({name, close});}
async function runCleanups(): Promise<void> { // Reverse order, and each one isolated: one failing pool should not // prevent the others from closing. for (const {name, close} of [...cleanups].reverse()) { try { await close(); console.log(`[shutdown] closed ${name}`); } catch (err) { console.error(`[shutdown] failed to close ${name}`, err); } }}Closing the HTTP server, including keep-alive sockets
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function closeServer(server: Server, timeoutMs: number): Promise<void> { return new Promise((resolve) => { let settled = false; const done = () => { if (!settled) { settled = true; resolve(); } };
// Stops accepting new connections. Existing ones keep being served, // and the callback fires once they are all done. server.close(done);
// The part people miss. An idle keep-alive socket is an open // connection, so server.close() waits for it even though no request // is in flight. Node 18.2+ lets us drop those immediately. server.closeIdleConnections?.();
// Last resort. If a request is genuinely stuck, we would rather // cut it than get SIGKILLed and cut everything. setTimeout(() => { console.warn('[shutdown] drain timed out, forcing connections closed'); server.closeAllConnections?.(); done(); }, timeoutMs).unref(); });}The signal handler
export function installShutdownHandlers(server: Server): void { const shutdown = async (signal: string) => { if (isShuttingDown) { console.log(`[shutdown] already shutting down, ignoring ${signal}`); return; } isShuttingDown = true; console.log(`[shutdown] ${signal} received`);
// 1. Fail readiness immediately. This starts the clock on // Kubernetes taking us out of the endpoint list. // 2. Keep serving while that propagates. await sleep(options.drainDelayMs);
// 3. Stop accepting, finish what is open. await closeServer(server, options.shutdownTimeoutMs);
// 4. Now nothing is using the database, so close it. await runCleanups();
console.log('[shutdown] done'); process.exit(0); };
process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT'));
// An unhandled rejection leaves the process in an unknown state. // Shut down rather than continuing to serve from it. process.on('unhandledRejection', (reason) => { console.error('[shutdown] unhandled rejection', reason); void shutdown('unhandledRejection'); });}Wiring it into Express
import { installShutdownHandlers, isReady, onShutdown,} from './graceful_shutdown';import express from 'express';import {Pool} from 'pg';
const app = express();const pool = new Pool({connectionString: process.env.DATABASE_URL});
// Readiness reflects shutdown state. Liveness must NOT: a shutting-down// process is unhealthy for traffic but perfectly alive, and failing// liveness here would make the kubelet kill it mid-drain.app.get('/readyz', (_req, res) => isReady() ? res.sendStatus(200) : res.sendStatus(503),);app.get('/livez', (_req, res) => res.sendStatus(200));
app.get('/', async (_req, res) => { const {rows} = await pool.query('select now()'); res.json(rows[0]);});
const server = app.listen(3000, () => console.log('listening on 3000'));
// Registered first, so it closes last, after the server has drained.onShutdown('postgres pool', () => pool.end());
installShutdownHandlers(server);Wiring it into Fastify
import {isReady, onShutdown} from './graceful_shutdown';import Fastify from 'fastify';import {Pool} from 'pg';
const app = Fastify({logger: true});const pool = new Pool({connectionString: process.env.DATABASE_URL});
app.get('/readyz', async (_req, reply) => isReady() ? reply.code(200).send() : reply.code(503).send(),);
app.addHook('onClose', async () => { await pool.end();});
await app.listen({port: 3000, host: '0.0.0.0'});
// Fastify's own close() already drains and runs onClose hooks, so the// only thing missing is the delay before it starts.const shutdown = async (signal: string) => { app.log.info(`${signal} received`); await new Promise((r) => setTimeout(r, 5_000)); // endpoint propagation await app.close(); process.exit(0);};
process.on('SIGTERM', () => void shutdown('SIGTERM'));process.on('SIGINT', () => void shutdown('SIGINT'));Usage and Benefits
The Kubernetes side has to agree
Why This Helps
The handler above is only half of it. The pod spec has to give it enough time, and the numbers have to add up.
spec: # 5s drain delay + 15s shutdown timeout + pool close, plus headroom. # If this is smaller than the sum, you get SIGKILLed mid-drain and # lose the requests you were protecting. terminationGracePeriodSeconds: 45 containers: - name: api readinessProbe: httpGet: {path: /readyz, port: 3000} periodSeconds: 5 livenessProbe: httpGet: {path: /livez, port: 3000} periodSeconds: 10Two things that quietly break this. If your container runs under npm start or a shell wrapper, PID 1 is the shell, and it does not forward SIGTERM to Node. Your handler never runs. Either exec the binary directly in CMD, or add an init like tini.
And a readinessProbe with a periodSeconds of 30 means up to 30 seconds before Kubernetes even notices you failed it, which is longer than your drain delay. Keep it at 5.
Verify it actually works
# Locally: watch a long request survive the signalcurl -s localhost:3000/slow &sleep 0.5kill -TERM $(pgrep -f 'node dist/index.js')# The curl should complete. The logs should show the ordered shutdown.
# In a cluster: hammer it while deleting the podkubectl run load --rm -it --image=williamyeh/hey -- \ -z 60s -c 20 http://api.default.svc.cluster.local/kubectl delete pod api-xxxx
# The success rate should stay at 100%. Any non-2xx is a dropped# request, and the usual cause is a drain delay that is too short.That second test is the only one that matters, because it exercises the endpoint propagation timing that you cannot reproduce locally. Run it before you believe any of this works.
Community Discussion
How do you handle this?
Your Turn
I’d like to know what drain delay you settled on and how you arrived at it. Five seconds is my default and I have no strong evidence for it beyond “it stopped the errors.” If you measured your actual endpoint propagation time, I want to hear the number and how you got it.
The other thing I’m curious about is queue consumers. HTTP draining is well understood, but a worker holding a long-running job when SIGTERM arrives is a harder problem. Do you finish the job, requeue it, or checkpoint it?
Alternative approaches
There are lighter options than writing this yourself.
terminus wraps the same logic with a nicer API and handles the probe endpoints for you. stoppable patches the server to track connections and close idle ones, which mattered more before closeIdleConnections() existed in core.
If you run a service mesh, some of this moves out of your process. You can configure Envoy to drain on its own, though you still need the sleep, because the mesh sidecar has its own shutdown ordering problem: if the proxy exits before your app finishes, your outbound calls start failing mid-drain.
And a preStop hook running sleep 5 moves the delay into the pod spec instead of your code. It works, it’s language-agnostic, and I don’t love it, because the delay is now invisible to anyone reading the application. Same result, worse discoverability.










