Graceful Shutdown
enableShutdownHooks turns SIGTERM into an ordered teardown; the exact order decides whether in-flight requests survive it.
app.enableShutdownHooks(signals?: ShutdownSignal[] | string[], options?: { useProcessExit?: boolean }): thisWhen Kubernetes, Docker or Ctrl-C stops your service, the process receives SIGTERM and has a few seconds to finish properly. Nest can turn that signal into an ordered teardown, but you have to ask for it: the listeners cost memory, so they are disabled by default.
app.enableShutdownHooks() registers process.on handlers for every member of the ShutdownSignal enum. Pass an array to narrow it. The hooks are interfaces from @nestjs/common rather than decorators: Nest looks for a method of the right name on each provider, meaning any class its container constructed, and calls it if present.
The order
- The HTTP adapter enters a closing state. With
return503OnClosing: true, added in NestJS 12 and implemented by the Express adapter, new requests get a 503 from here on. onModuleDestroy().beforeApplicationShutdown(signal).- Gateways, microservice transports, then the HTTP server close. Requests already in flight finish here.
onApplicationShutdown(signal).
Only the last two receive the signal string. Steps 2 and 3 run before the server stops, which is the trap: destroy a database pool in onModuleDestroy and a request still being served loses its connection mid-query. Pool and client teardown belongs in onApplicationShutdown, which is where @nestjs/typeorm destroys its own DataSource.
Repeated signals coalesce, so a second SIGTERM waits on the shutdown already running. app.close() runs the same sequence with no signal, and does not exit the process: an open interval or socket keeps Node alive afterwards.
Example
// src/main.ts
import { ShutdownSignal } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// NestJS 12, Express adapter only: once shutdown starts, new requests get
// 503 with Connection: close, while requests already being handled finish.
return503OnClosing: true,
// Do not wait for idle keep-alive sockets to time out.
forceCloseConnections: true,
});
// Listen to these two signals only. Calling enableShutdownHooks() with no
// argument is the longhand-free form and listens to every ShutdownSignal
// member, including SIGHUP, SIGQUIT and the fatal ones.
app.enableShutdownHooks([ShutdownSignal.SIGTERM, ShutdownSignal.SIGINT], {
// Finish with process.exit() rather than re-raising the signal, so Node
// fires its 'exit' event and an async logger transport can flush.
// Side effect: the exit code becomes 0 instead of 143 for SIGTERM, which
// some orchestrators and CI jobs read.
useProcessExit: true,
});
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();When to use it
- Stop a rolling deploy from returning 502s because pods are killed mid-request.
- Finish writing a batch of records to Postgres before the container exits.
- Work out why a Pino transport loses the last few log lines when a pod restarts.
- Shut a standalone worker down cleanly when its Kubernetes Job is cancelled.
More examples
Draining background work, in the right hook
The split between the two hooks is the whole point. Work that must finish goes in beforeApplicationShutdown, because requests are still arriving and completing; resources those requests need go in onApplicationShutdown, after the server has closed. Reach for this shape whenever a service owns something long-running that is not tied to a single request: a worker loop, a batch flusher, an outbound connection pool.
// src/reports/report-worker.service.ts
import {
BeforeApplicationShutdown,
Injectable,
Logger,
OnApplicationShutdown,
} from '@nestjs/common';
import { Pool } from 'pg';
@Injectable()
export class ReportWorker
implements BeforeApplicationShutdown, OnApplicationShutdown
{
private readonly logger = new Logger(ReportWorker.name);
private readonly pool = new Pool({ connectionString: process.env.DATABASE_URL });
private readonly running = new Set<Promise<unknown>>();
private accepting = true;
enqueue(task: () => Promise<void>): Promise<void> {
if (!this.accepting) {
throw new Error('ReportWorker is shutting down');
}
const job = task().finally(() => this.running.delete(job));
this.running.add(job);
return job;
}
// Step 3 of the sequence: the server is still up. Stop taking new work and
// wait for what has already started.
async beforeApplicationShutdown(signal: string): Promise<void> {
this.accepting = false;
this.logger.log(`${signal}: draining ${this.running.size} job(s)`);
await Promise.allSettled([...this.running]);
}
// Step 5: the HTTP server has closed, so nothing can still need a
// connection. Only now is it safe to end the pool.
async onApplicationShutdown(signal: string): Promise<void> {
await this.pool.end();
this.logger.log(`${signal}: pool closed`);
}
}
// src/reports/reports.module.ts
import { Module } from '@nestjs/common';
import { ReportWorker } from './report-worker.service.js';
@Module({
// Shorthand: the class is both the token other classes ask for and the
// class Nest instantiates.
providers: [ReportWorker],
// Longhand, identical in effect:
// providers: [{ provide: ReportWorker, useClass: ReportWorker }],
exports: [ReportWorker],
})
export class ReportsModule {}Pausing before teardown so the load balancer notices
A module is the unit Nest uses to group providers and decide what is visible to what; forRoot is the convention for a module configured once at the root. The delay exists because removing a pod from load balancing and signalling the pod are separate, concurrent events: without a pause, requests keep arriving for a second or two after teardown has begun. Use this when you serve HTTP behind any load balancer, and set the delay shorter than the orchestrator's grace period.
// src/health/health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller.js';
@Module({
imports: [
TerminusModule.forRoot({
// Wait this long after the signal before the application shuts down.
// Default is 0. Five seconds gives the readiness probe time to fail and
// the load balancer time to stop routing new requests here.
gracefulShutdownTimeoutMs: 5_000,
errorLogStyle: 'json',
}),
],
controllers: [HealthController],
})
export class HealthModule {}A one-shot job, closed by hand
This is the shape for a migration runner, a nightly reindex or anything you run as a Kubernetes Job: you get the container, the config and the providers without opening a port. The detail worth remembering is that beforeApplicationShutdown(signal: string) is typed as taking a string but receives undefined on an explicit close(), so do not interpolate it into a log line without a fallback.
// src/tasks/rebuild-index.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module.js';
import { SearchService } from '../search/search.service.js';
// An application context has no HTTP server: no routes are registered and
// nothing listens. Providers and modules work exactly as they do in a server.
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
// Still worth enabling: if the job is cancelled, the container gets SIGTERM
// and the same teardown runs instead of being cut off mid-write.
app.enableShutdownHooks();
try {
await app.get(SearchService).rebuild();
} finally {
// Runs onModuleDestroy, beforeApplicationShutdown and onApplicationShutdown
// with no signal, so the signal parameter arrives undefined. close() does
// not end the process; the script simply runs out of work afterwards.
await app.close();
}
Discussion