Nobody writes graceful shutdown until it bites them. I didn’t. My app handled requests fine, my code was clean, and I never once thought about what happens when the process gets told to stop.

Then we moved to rolling deploys, and every deploy started dropping a handful of requests. Users saw random errors for about ten seconds every time we shipped. The code was correct. The shutdown was not.

Here’s the part everyone skips, why it matters, and the pattern I use in every service now.


What actually happens on deploy

When your platform deploys a new version, it tells the old process to stop. It sends a SIGTERM signal. Then it waits a bit, and if you haven’t exited, it sends SIGKILL, which is not negotiable.

By default, Node doesn’t do anything smart with SIGTERM. The process just exits. Any request being handled right then gets cut off mid-response. Any database write in flight might be half-done. Any background job you picked up but didn’t finish is now lost.

The user sees a failed request. You see nothing, because the process is gone before it can log anything useful. That’s the whole problem in one sentence: your app dies instead of finishing what it started.


The naive fix that isn’t enough

The first thing people do is listen for the signal and close the server:

process.on("SIGTERM", () => {
  server.close()
  process.exit(0)
})

This is closer but still wrong in two ways. process.exit(0) fires immediately, before server.close() has actually finished draining. And you’re not waiting for anything else, like open database connections or in-flight jobs.

server.close() is the right idea though. It stops accepting new connections but lets existing requests finish. The mistake is not waiting for it to actually complete before killing the process.


The pattern that works

Here’s what I run. Stop taking new work, finish the work you have, close your resources, then exit. In that order.

let shuttingDown = false

async function shutdown(signal: string) {
  if (shuttingDown) return // ignore repeat signals
  shuttingDown = true
  console.log(`${signal} received, shutting down`)

  // 1. stop accepting new connections, wait for in-flight to finish
  await new Promise<void>((resolve, reject) => {
    server.close((err) => (err ? reject(err) : resolve()))
  })

  // 2. close the things the requests depended on
  await db.end()
  await redis.quit()

  console.log("clean shutdown complete")
  process.exit(0)
}

process.on("SIGTERM", () => shutdown("SIGTERM"))
process.on("SIGINT", () => shutdown("SIGINT"))

The shuttingDown guard matters because you can get the signal more than once, and you don’t want two shutdowns racing. Handling SIGINT too means Ctrl+C locally behaves the same as a real deploy, so you test the actual path every time you stop the dev server.

The order is the point. Close the server first so no new request grabs a connection you’re about to kill. Then close the connections.


The timeout you must have

Here’s the failure I hit next. A slow request, or a connection that won’t close, means server.close() hangs forever. Now your process never exits on its own, so the platform waits its grace period and then SIGKILLs you anyway. You’re back to an ungraceful shutdown, just slower.

So you cap it. Give shutdown a deadline, and if it blows past it, force exit.

async function shutdown(signal: string) {
  if (shuttingDown) return
  shuttingDown = true

  // hard cap: if we're not done in 10s, force it
  const killer = setTimeout(() => {
    console.error("shutdown timed out, forcing exit")
    process.exit(1)
  }, 10_000)
  killer.unref() // don't let this timer keep the process alive

  await new Promise<void>((resolve) => server.close(() => resolve()))
  await db.end()

  clearTimeout(killer)
  process.exit(0)
}

Set the timeout shorter than your platform’s grace period. If Kubernetes gives you 30 seconds before SIGKILL, your own timeout should be 25, so you control the exit instead of getting force-killed. unref() keeps the timer from being the reason the process stays alive.


Health checks and the drain gap

This is the subtle one that fixed my dropped-requests problem completely.

When the load balancer sends SIGTERM, it doesn’t stop routing traffic to you instantly. There’s a lag between “we told this instance to stop” and “we stopped sending it requests.” During that gap, calling server.close() means new requests arrive and get refused. Dropped requests, right at the end.

The fix is to fail your health check first, then wait a couple seconds before closing the server. That way the load balancer sees you as unhealthy and stops routing to you before you actually stop listening.

let healthy = true
app.get("/health", (_, res) =>
  res.status(healthy ? 200 : 503).end(),
)

async function shutdown() {
  healthy = false            // start failing health checks
  await sleep(3_000)         // let the LB notice and stop routing
  await new Promise((r) => server.close(r))
  await db.end()
  process.exit(0)
}

Those three seconds of doing nothing felt wrong to add. They’re the thing that took my deploys from “drops requests” to “drops nothing.” The gap is real and you have to wait it out.


Don’t forget background work

If your process does more than serve HTTP, the same logic applies to all of it. A worker that pulled a job off a queue needs to either finish it or put it back before exiting. A cron that’s mid-run needs to be let finish.

The rule is the same everywhere: on SIGTERM, stop picking up new work, let the current work drain, then release resources and exit. HTTP requests are just the most visible case. A dropped background job is the same bug, you just don’t see it until the data’s wrong.


Graceful shutdown is the code you write after your first bad deploy, never before. It’s not clever and it’s not fun. It’s a signal handler, an ordered cleanup, a timeout so you can’t hang, and a few seconds of patience so the load balancer keeps up. Twenty lines total. Skip them and every deploy quietly costs you a handful of requests and the occasional half-finished write. Write them once and deploys stop being a thing users can feel.