A rolling deploy that SIGKILLs in-flight POSTs is just a distributed partial-failure generator. Kubernetes preStop + terminationGracePeriodSeconds and ECS stop timeouts only help if your Node process actually stops accepting, drains HTTP, nacks queue ownership cleanly, and exits 0 before the grace window. The unfair advantage is a single shutdown orchestrator every entrypoint shares — not ad-hoc process.on('SIGTERM') snippets per service.
⚡ TL;DR: On SIGTERM: fail readiness immediately, stop queue consumers,
server.close(), await in-flight with a deadline, flush telemetry, exit. Align K8spreStopsleep with ALB deregistration and ECSstopTimeout. Pair with Node Watch Mode in Staging and Lambda Alias Traffic Shifting for complementary deploy safety.
One orchestrator, hard deadlines
// src/shutdown.ts
import type { Server } from "node:http";
type Closer = () => Promise<void>;
const closers: Closer[] = [];
let shuttingDown = false;
export function onShutdown(fn: Closer) { closers.push(fn); }
export function isShuttingDown() { return shuttingDown; }
export function installShutdown(server: Server, budgetMs = 25_000) {
const run = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(JSON.stringify({ msg: "shutdown_start", signal }));
readiness.set(false); // ✅ ALB/K8s stop new traffic
const timer = setTimeout(() => {
console.error(JSON.stringify({ msg: "shutdown_timeout" }));
process.exit(1);
}, budgetMs);
try {
await new Promise<void>((res) => server.close(() => res()));
for (const c of closers) await c();
clearTimeout(timer);
process.exit(0);
} catch (err) {
console.error(err);
process.exit(1);
}
};
process.on("SIGTERM", () => void run("SIGTERM"));
process.on("SIGINT", () => void run("SIGINT"));
}
// ❌ Ignoring SIGTERM — K8s waits, then SIGKILL mid-request
// no handler at all
Stop consumers before HTTP drain finishes
// src/sqs-consumer.ts
import { onShutdown } from "./shutdown.js";
let running = true;
onShutdown(async () => {
running = false; // ✅ stop long-polling
await inFlight.drain(); // wait for handlers with AbortSignal
});
export async function loop(poll: () => Promise<Message[]>) {
while (running) {
const batch = await poll();
await Promise.all(batch.map((m) => handle(m, AbortSignal.timeout(10_000))));
}
}
Align platform timers
| Platform | Knob | Typical |
|---|---|---|
| K8s | preStop: sleep 5–10 |
Let endpoint slice propagate |
| K8s | terminationGracePeriodSeconds |
30–60 ≥ app budget |
| ALB | deregistration delay | Match preStop |
| ECS | stopTimeout |
≥ app budget + buffer |
| Node | shutdown budget | Leave 5s for SIGKILL margin |
# deployment.yaml excerpt
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 45
Closing checklist
✅ Dos
– ✅ Fail readiness first on SIGTERM
– ✅ server.close() + await in-flight with deadline
– ✅ Stop queue/socket consumers explicitly
– ✅ Align preStop, deregistration, and stopTimeout
– ✅ Test with chaos: kill pods under load, measure 5xx
❌ Don’ts
– ❌ Don’t exit 0 while requests are still writing bodies
– ❌ Don’t set grace period shorter than app budget
– ❌ Don’t keep readiness green during drain
– ❌ Don’t forget open DB pools and telemetry flush
Related reading
- Node Watch Mode in Staging: Rapid Loops Without Leaking Watchers
- Blue/Green Stateful Workers: Drain In-Flight Jobs Before Old Tasks Die
- Lambda Alias Traffic Shifting: Automated Rollback Hooks That Fire
- Node undici Dispatcher Pools: Kill Keep-Alive Storms Under Spikes
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
