When an application receives a SIGTERM signal, it must not tear database connections apart in the middle of work. An unfinished transaction means data loss or a state that will have to be fixed by hand. Let's look at how to properly finish working with the database in NestJS.
Why the closing order matters
Imagine: the service received SIGTERM and immediately closed the connection pool. At that moment an HTTP handler is still processing a request with an open transaction. The transaction is broken, the data is not written — the client got an error, and the database is left in an unknown state.
NestJS solves this problem with two lifecycle stages during shutdown:
beforeApplicationShutdown— called first. Here you need to wait for all active operations: HTTP requests, background tasks, queues.onApplicationShutdown— called after. Only here do you close the connection pool.
The rule is simple: the pool is closed last, after everything else.
Closing the pg pool
Implement the OnApplicationShutdown interface in the service that holds the pool:
import { Module, OnApplicationShutdown, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
@Injectable()
export class DatabaseService implements OnApplicationShutdown {
readonly pool: Pool;
constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.pool.on('error', () => {});
await this.pool.end();
}
}
The line this.pool.on('error', () => {}) before pool.end() suppresses errors from connections that are being closed in the process — this is expected behavior, not a failure.
TypeORM
For TypeORM the principle is the same, except that instead of pool.end() you call dataSource.destroy():
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Injectable()
export class TypeOrmShutdownService implements OnApplicationShutdown {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onApplicationShutdown(): Promise<void> {
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
}
}
}
The isInitialized check protects against an error on a repeated call: if the DataSource is already closed, a second destroy() will throw an exception.
How to wait for active transactions
Different transaction sources require different approaches.
An HTTP handler with a transaction
The handler opens a transaction, makes an insert into the database, and commits:
@Injectable()
export class CreateOrderHandler {
constructor(private readonly db: DatabaseService) {}
async execute(customerId: string, amount: number): Promise<{ id: string }> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: string }>(
`INSERT INTO orders (customer_id, amount, status)
VALUES ($1, $2, 'pending')
RETURNING id`,
[customerId, amount],
);
await client.query('COMMIT');
return { id: rows[0].id };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
Here you don't need to do anything extra. On receiving SIGTERM, NestJS calls app.close(), which closes the HTTP server via server.close(). This means: new requests are not accepted, and the current request waits for COMMIT or ROLLBACK and finishes. Only after that does Nest move on to the onApplicationShutdown phase and close the pool.
A background scheduler with a transaction
The scheduler is trickier: it runs on a timer and may be in the middle of an iteration at the moment of shutdown. You need to track the current iteration manually:
@Injectable()
export class OutboxRelayService implements OnApplicationShutdown {
private inflightPromise: Promise<void> | null = null;
constructor(
private readonly db: DatabaseService,
private readonly shutdownState: ShutdownStateService,
) {}
@Interval(5_000)
async processOutboxBatch(): Promise<void> {
if (this.shutdownState.isDraining()) return;
const work = this._doProcessBatch();
this.inflightPromise = work;
await work;
this.inflightPromise = null;
}
private async _doProcessBatch(): Promise<void> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query<{ id: string; payload: string }>(
`SELECT id, payload FROM outbox
WHERE dispatched_at IS NULL
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 20`,
);
for (const event of rows) {
await client.query(
`UPDATE outbox SET dispatched_at = now() WHERE id = $1`,
[event.id],
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async beforeApplicationShutdown(): Promise<void> {
if (this.inflightPromise) {
await this.inflightPromise;
}
}
}
isDraining() is checked before the start of each new iteration — one already begun is not interrupted. In beforeApplicationShutdown we wait for the current iteration via inflightPromise. The pool will only close after that.
A BullMQ worker with a transaction
BullMQ provides its own shutdown mechanism — worker.close(). It waits for the current job before stopping:
@Processor('product-index')
@Injectable()
export class ProductIndexWorker extends WorkerHost implements BeforeApplicationShutdown {
constructor(private readonly db: DatabaseService) {
super();
}
async process(job: { data: { productId: string } }): Promise<void> {
const client = await this.db.pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE products SET indexed_at = now() WHERE id = $1`,
[job.data.productId],
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async beforeApplicationShutdown(): Promise<void> {
await Promise.race([
(this.worker as Worker).close(),
new Promise<void>((resolve) => setTimeout(resolve, 20_000).unref()),
]);
}
}
Promise.race with a 20-second timeout is protection against a hung job. If the worker did not finish within 20 seconds, beforeApplicationShutdown still returns control to Nest, which continues the shutdown. An unfinished transaction will get a ROLLBACK from pg when the connection is severed.
Migrations are run only at startup
Migrations are applied once — before the service starts accepting requests:
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
const dataSource = app.get(DataSource);
await dataSource.runMigrations();
await app.listen(3000);
}
bootstrap();
No "reverse migrations" are run on shutdown. dataSource.destroy() closes connections, it does not roll back the schema. This is intentional: the database schema must not depend on whether the service is running or not.
Common mistakes
Closing the pool in beforeApplicationShutdown. At this stage HTTP requests and background tasks may still be running — they need the pool. Close the pool only in onApplicationShutdown.
No await inflightPromise in the scheduler. If you don't wait for the current iteration in beforeApplicationShutdown, the pool will close before the scheduler finishes its transaction — you will get an error in the middle of a write.
pool.end() in process.on('SIGTERM', ...) before app.close(). Don't bypass the NestJS lifecycle hooks — they give the correct order without manual orchestration.
worker.close(true) with the force flag. This mode kills the job without waiting for completion. Use worker.close() with no arguments — BullMQ will wait for the active job itself.
logger.error on a normal pool.end(). A regular pool close is an expected event; log it at the INFO level, otherwise every deployment will create noise in the alert channel.
In short
- The connection pool is closed in
onApplicationShutdown— after HTTP drain and background tasks. - Don't close the pool in
beforeApplicationShutdown: transactions may still be in progress in this phase. - HTTP transactions are waited for automatically via
server.close()duringapp.close(). - Schedulers: store the current iteration in
inflightPromiseand wait for it inbeforeApplicationShutdown. - BullMQ:
worker.close()withoutforce— the worker will wait for the active job itself. - TypeORM:
dataSource.destroy()with anisInitializedcheck before the call. - Migrations are run only at startup, before
app.listen(); on shutdown the schema is left untouched.
What to read next
- HTTP drain in Node.js —
server.close(), long endpoints, timeouts. - Background tasks and queues — scheduler, outbox, BullMQ.
- Kafka on shutdown —
consumer.disconnect(), commit semantics.