What is graceful shutdown in NestJS?
Graceful shutdown means that when the platform asks a NestJS process to stop, it finishes or hands back the work it has already accepted and then exits cleanly. Without it, the process dies with requests half-served, jobs half-processed and database transactions left open. Behind an orchestrator or load balancer, processes are stopped routinely during deploys, autoscaling and node drains. An abrupt exit therefore shows up as 502s, retried jobs and connection errors during every rollout.
In NestJS, graceful shutdown has two parts. The first is a framework feature: app.enableShutdownHooks() connects OS termination signals to Nest's lifecycle hooks. The second is application design. That design is the ordered sequence of steps your code runs inside those hooks to stop intake, drain work and release connections.
Who should use it?
Use a deliberate shutdown sequence if any of these apply to your NestJS service:
- It runs behind a load balancer or in Kubernetes, where pods are replaced on every deploy and on scale-in. Each replacement is a shutdown, so any gap in the sequence turns into client-visible errors on every rollout.
- It consumes from a queue or broker. An abrupt exit leaves jobs half-done, and they are redelivered later.
- It holds database transactions, Redis clients or broker channels that should be committed, rolled back or closed rather than cut off.
- It buffers state in memory, such as metric batches, write-behind buffers or audit records, that is lost if the process is killed.
It matters less for short-lived CLI scripts, local development servers and processes whose work is entirely idempotent and stateless. On platforms that do not reliably deliver SIGTERM, the full sequence may never run, which the last section covers. Teams in those situations can rely mainly on redelivery and idempotency instead of an in-process drain.
How does it work?
The mechanism has four parts:
- The platform sends a signal. Kubernetes, Docker and most process managers send
SIGTERMto the container's main process. If the process has not exited when the grace period ends, they sendSIGKILL. - Nest turns the signal into lifecycle hooks. With
enableShutdownHooks()enabled, Nest callsapp.close()when the signal arrives. That runsonModuleDestroy, thenbeforeApplicationShutdown, then closes the HTTP server, then runsonApplicationShutdown. Nest awaits each phase before starting the next. - Your code orders the work. Inside those hooks you fail readiness so traffic stops arriving. You pause queue consumers, let in-flight HTTP requests and jobs finish, flush in-memory state, and close database, Redis and broker connections last.
- A hard timer bounds the whole thing. If any step hangs, the process logs which step was still running and exits before the platform's
SIGKILLarrives.
The sections below cover each part in order.
What enableShutdownHooks() does, and what it leaves to you
By default, Nest does not react to OS signals. app.enableShutdownHooks() registers process listeners for termination signals such as SIGTERM and SIGINT. You can also pass an explicit list, for example ['SIGTERM', 'SIGINT']. When a signal arrives, Nest closes the application, which runs onModuleDestroy, then beforeApplicationShutdown, then onApplicationShutdown. The last two receive the signal name. Without the call, those hooks still run if your own code calls app.close(), but nothing runs on a signal.
The hooks are opt-in because each listener lives on the global process object. If you create several Nest apps in one process, which is common in test suites, every app adds its own listeners. Node will then warn about possible listener leaks.
The key limitation is that the hooks only deliver the signal. They do not decide the shutdown order. You design that sequence yourself: fail readiness, stop accepting new work, drain HTTP and jobs, then close broker and database connections.
In main.ts, that means calling app.enableShutdownHooks() before app.listen(). Then put the sequence in one injectable ShutdownService that implements beforeApplicationShutdown, so the ordering lives in one place rather than being spread across every module.
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Opt in: run the shutdown lifecycle when the platform sends a signal.
app.enableShutdownHooks(['SIGTERM', 'SIGINT']);
await app.listen(3000);
}
bootstrap();
// shutdown.service.ts (register as a provider in AppModule)
import { BeforeApplicationShutdown, Injectable, Logger } from '@nestjs/common';
@Injectable()
export class ShutdownService implements BeforeApplicationShutdown {
private readonly logger = new Logger(ShutdownService.name);
private shuttingDown = false;
// The readiness endpoint reads this so the load balancer stops routing here.
isShuttingDown(): boolean {
return this.shuttingDown;
}
// Nest only delivers the signal. The order of the steps below is your design.
async beforeApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(`Received ${signal ?? 'app.close()'}, starting shutdown`);
// 1. Fail readiness.
this.shuttingDown = true;
// 2. Stop accepting new work, for example by pausing queue consumers.
// 3. Drain in-flight HTTP requests and running jobs.
// 4. Close broker and database connections.
}
}
A minimal main.ts that opts into shutdown hooks, plus a ShutdownService skeleton that keeps the whole shutdown sequence in one beforeApplicationShutdown method.
How the shutdown lifecycle is ordered and where each cleanup belongs
A single service keeps the shutdown sequence in one place instead of scattering it across modules. To place each step correctly, you need the order in which Nest runs its hooks after the signal arrives:
SIGTERM
│
├─ onModuleDestroy() all modules, no signal argument
├─ beforeApplicationShutdown() receives the signal name
├─ HTTP server close stops accepting, waits for open requests
├─ onApplicationShutdown() receives the signal name
│
process exit
Nest awaits each phase before it starts the next. Any async work in a hook therefore extends shutdown, and a hook that never resolves stalls it.
| Hook | When it runs | Appropriate work |
|---|---|---|
onModuleDestroy |
First, while HTTP traffic is still being served | Release purely local resources: timers, intervals, in-memory caches |
beforeApplicationShutdown |
Before the HTTP server closes | Stop queue consumers from taking new jobs and wait for current jobs to finish |
onApplicationShutdown |
After the HTTP server has closed | Close database pools, Redis clients and broker connections |
Stop the consumers before the server closes. A worker should not pick up new jobs while the process is on its way out, and finishing current jobs early leaves the remaining time for HTTP draining.
Do not close the database pool in onModuleDestroy. That hook runs while requests are still in flight, so any request that reaches a query after the pool closes fails with a connection error. The client returns a 500 even though the shutdown was meant to be graceful. Connections that requests and jobs depend on must close last.
Hook order is also too coarse for exact sequencing. Within a phase, Nest decides the order across modules from the module graph, not from your intent. Hooks on several providers in the same phase may run concurrently. You cannot reliably write "pause the email consumer, wait for the export job, then flush metrics" across three modules. A single orchestrating service can, because it calls each step explicitly and awaits it.
SIGTERM
│
▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ onModuleDestroy() │────▶│ clear timers and intervals │
│ HTTP still serving requests │ │ drop in-memory caches │
└──────────────┬───────────────┘ └──────────────────────────────┘
▼ (awaited)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ beforeApplicationShutdown(s) │────▶│ stop queue consumers │
│ HTTP still serving requests │ │ await in-flight jobs │
└──────────────┬───────────────┘ └──────────────────────────────┘
▼ (awaited)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ HTTP server close │────▶│ refuse new connections │
│ (run by Nest) │ │ wait for open requests │
└──────────────┬───────────────┘ └──────────────────────────────┘
▼ (awaited)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ onApplicationShutdown(s) │────▶│ close DB pool, Redis client, │
│ no requests in flight │ │ broker connections │
└──────────────┬───────────────┘ └──────────────────────────────┘
▼
process exit
✗ DB pool closed in onModuleDestroy ──▶ draining requests fail (500)
From SIGTERM to process exit, Nest awaits each shutdown phase in turn, so local cleanup goes first, queue consumers stop before the HTTP server closes, and database, Redis and broker clients close last in onApplicationShutdown.
Getting SIGTERM to Node and failing readiness in Kubernetes
That orchestration only matters if the signal arrives. Kubernetes sends SIGTERM to a container's main process during rolling deploys, scale-in and node drains. If Node never sees it, nothing runs, and the kubelet sends SIGKILL once the grace period expires.
Two packaging mistakes cause most of the misses. A shell-form CMD node dist/main.js makes /bin/sh PID 1, and the shell may not forward signals to its child. Wrapping the app in npm start adds another process that may not relay SIGTERM reliably. Use exec form (CMD ["node", "dist/main.js"]) so Node is PID 1, or run a minimal init such as tini when you need zombie reaping. To verify, run docker stop on the container and confirm your shutdown log lines appear before it exits. A ten-second hang followed by an abrupt exit means the signal was swallowed.
Readiness comes next. On SIGTERM, set a shutdown flag so the readiness endpoint returns 503 while the server keeps serving. Endpoint removal propagates asynchronously to kube-proxy, ingress controllers and cloud load balancers, so routing continues for several seconds after termination starts. A preStop sleep, or a short delay in your handler, covers that window. Leave liveness unaffected so the kubelet does not restart a pod that is draining correctly. Set terminationGracePeriodSeconds to cover the preStop delay plus your full drain.
The delay is fixed latency added to every termination. If it is too short, requests are dropped. If it is too long, deploys slow down and capacity sits idle.
spec:
# Covers the preStop sleep plus the full in-app drain.
# The countdown starts before preStop runs, so budget both here.
terminationGracePeriodSeconds: 45
containers:
- name: api
image: registry.example.com/orders-api:1.4.2
ports:
- containerPort: 3000
lifecycle:
preStop:
exec:
# Runs before SIGTERM is sent. While it sleeps, endpoint removal
# reaches kube-proxy, ingress and cloud load balancers.
# Requires a sleep binary in the image (absent in distroless).
command: ["sleep", "10"]
readinessProbe:
# Returns 503 once the shutdown flag is set, so the pod leaves
# Service endpoints while it keeps serving in-flight traffic.
httpGet:
path: /health/ready
port: 3000
periodSeconds: 5
failureThreshold: 1
livenessProbe:
# Separate endpoint that ignores the shutdown flag, so the kubelet
# does not restart a pod that is draining correctly.
httpGet:
path: /health/live
port: 3000
periodSeconds: 10
failureThreshold: 3
A pod spec that pairs a preStop sleep with a grace period sized for the full drain, and keeps readiness and liveness on separate endpoints so a draining pod is pulled from routing without being restarted.
Draining in-flight HTTP requests and keep-alive connections
Calling close() on the underlying server stops it accepting new connections and lets in-flight requests finish. Idle keep-alive sockets are a separate problem. A client holding an idle pooled connection does not count as an in-flight request, but the socket can still keep the server from finishing its close. Depending on your Node version, the server may wait until each idle socket hits keepAliveTimeout. A client that sends a request on that socket during the drain gets it served, which pushes completion back further.
The fix is to tell clients to stop reusing connections. Add a middleware that checks a shutdown flag. Once shutdown begins, it sets Connection: close on every response, so each socket closes after its current request. Set the flag from the same signal handler that fails readiness.
app.getHttpServer() returns the Node server with either adapter, so keepAliveTimeout, headersTimeout and requestTimeout can be set on it directly. Keep keepAliveTimeout shorter than your drain budget, but longer than the idle timeout of any load balancer in front of the app. Otherwise the proxy may reuse a socket that Node has already closed.
| Concern | Express adapter | Fastify adapter |
|---|---|---|
| Timeouts | Set on getHttpServer() |
keepAliveTimeout, connectionTimeout, requestTimeout in adapter options |
| Idle sockets on close | Handled by Node; your Node version determines the behaviour | forceCloseConnections option |
| Header in middleware | res.setHeader |
res.setHeader on the raw response |
Forcing closure has a cost. If you destroy idle sockets, clients that handle connection closure poorly will see resets. Some retry non-idempotent requests, and others report errors. Start with Connection: close, and only destroy remaining sockets when the time budget runs out.
import { NestFactory } from '@nestjs/core';
import type { Server } from 'http';
import type { Request, Response, NextFunction } from 'express';
import { AppModule } from './app.module';
let shuttingDown = false;
async function bootstrap() {
const app = await NestFactory.create(AppModule); // Express adapter
// Once draining starts, tell clients to drop the socket after this response
// instead of returning it to their keep-alive pool.
app.use((req: Request, res: Response, next: NextFunction) => {
if (shuttingDown) res.setHeader('Connection', 'close');
next();
});
// getHttpServer() returns the underlying Node http.Server.
const server: Server = app.getHttpServer();
// Longer than the load balancer's idle timeout (assumed 60s here) so the
// proxy never reuses a socket Node already closed, but shorter than the
// drain budget (assumed 90s here).
server.keepAliveTimeout = 65_000;
// Conventionally kept above keepAliveTimeout.
server.headersTimeout = 66_000;
// Upper bound on a single request, so one slow request cannot stall the drain.
server.requestTimeout = 30_000;
process.once('SIGTERM', async () => {
// Set in the same handler that fails readiness.
shuttingDown = true;
// Stops accepting new connections and waits for in-flight requests.
await app.close();
process.exit(0);
});
await app.listen(3000);
}
bootstrap();
A NestJS bootstrap using the Express adapter that sets a shutdown flag on SIGTERM, adds Connection: close to every response once draining begins, and tunes the Node server's keep-alive, header and request timeouts through app.getHttpServer().
Draining queue consumers, then closing database and broker connections
Queue consumers need the same two-phase treatment, and the first phase should run in beforeApplicationShutdown or earlier. Define a small interface that each adapter implements, for example pause(), activeCount() and close(). Call pause() first, so the worker stops fetching or accepting new jobs while the jobs it already holds keep running. Then poll activeCount() until it reaches zero or a deadline passes. The deadline must fit inside the pod's termination budget.
When the deadline expires, do not acknowledge the jobs that are still running. With ack-based brokers, unacknowledged messages are redelivered once the channel closes. With visibility-timeout queues, the message reappears after its timeout. Either way the job runs again, possibly after partial side effects, so handlers must be idempotent. Dedupe keys, upserts and conditional writes handle most cases. Do not try to wait out long jobs. Make them checkpoint progress so a redelivered job resumes from its last saved step.
Close the shared clients only in onApplicationShutdown, after every consumer and HTTP handler has stopped. This includes ORM or driver pools, Redis and broker connections. If you close them earlier, in-flight work fails with "connection closed" errors that look like real bugs. Log each close, with its duration and any error, so a hung pool shows up in the logs.
In-memory state needs its own decision. This covers metric batches, write-behind buffers, aggregated counters and caches. Either flush it explicitly before the clients it depends on close, or design it so losing it is acceptable. A cache can be rebuilt after a restart, while an unflushed audit batch cannot.
Enforcing a time budget and testing the shutdown
None of that cleanup helps if one step hangs. Wrap the entire sequence in a hard timer set below terminationGracePeriodSeconds. When the timer fires, log which step was still running and call process.exit(1). The non-zero exit code makes a stuck drain show up in restart metrics instead of passing as a clean stop. Call unref() on the timer so the timer itself never keeps the process alive.
The timer matters because the alternative is worse. When the grace period expires, Kubernetes sends SIGKILL, which skips every hook. Treat graceful shutdown as a way to reduce errors. Idempotent handlers and broker redelivery are what actually protect your data.
Illustrative estimate (assumed numbers, not a benchmark)
readiness delay + HTTP drain + job drain + pool close + margin ≤ grace period
| Phase | Assumed budget |
|---|---|
| Readiness delay | 5s |
| HTTP drain | 10s |
| Job drain | 10s |
| Pool and broker close | 2s |
| Safety margin | 3s |
| Total (grace period) | 30s |
With these numbers, set the internal timer to 27s, which is everything except the margin.
Testing it locally
- Add an endpoint that sleeps about 10s and a job that runs about 15s.
- Call the endpoint, enqueue the job, then run
kill -TERM <pid>. - Assert that the slow request returns 200 and the job is acknowledged.
- Assert that a new request is refused and the process exits with code 0 inside the budget.
In staging, watch each deploy for 5xx spikes and connection-reset errors in client and load balancer logs.
E2E suites that create many app instances with enableShutdownHooks() keep adding process signal listeners and eventually trigger MaxListenersExceededWarning. Call app.close() in afterEach/afterAll, which unsubscribes them, or leave hooks disabled in tests that don't exercise shutdown.
When to avoid it, alternatives, and impact on cost and engineering time
Skip the full drain sequence where the process model doesn't fit it. On serverless platforms the runtime may freeze or discard the instance without delivering SIGTERM, so signal listeners might never run. When several Nest applications share one process, each registers its own listeners, and the first app to call process.exit() cuts off the others' cleanup. Jobs that run longer than any grace period you would accept, such as hour-long exports, can't be drained at all. They need checkpointing so a replacement worker resumes from the last saved step.
| Alternative | What it covers | What it misses |
|---|---|---|
| Redelivery + idempotent handlers | Lost queue messages | Dropped HTTP requests; duplicate side effects if idempotency is incomplete |
| preStop sleep only | Endpoint propagation delay | In-flight work that outlasts the sleep; unclosed pools |
| Sidecar or load-balancer connection draining | New traffic routing, keep-alive teardown | Queue consumers and DB connections inside the app |
| Resumable (checkpointed) jobs | Arbitrarily long work | Adds state storage and resume logic to every job type |
Drains are not free. Every second of drain time is added to each rollout batch and to every scale-in event.
Illustrative estimate (assumed numbers, not a benchmark): added rollout time = (replicas ÷ pods replaced per batch) × drain seconds. With 12 replicas, 3 per batch and a 30 s drain: (12 ÷ 3) × 30 = 120 s extra per deploy.
The upfront cost is building and testing the sequence once. A single shutdown service that owns the ordering (stop intake, drain HTTP, drain consumers, close pools) pays that cost back in two ways. You get fewer deploy-time incidents from dropped requests and lost jobs. You also avoid the cleanup hooks that otherwise end up scattered across modules, each with its own timeout and its own assumptions about ordering.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs graceful shutdown, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.