open atlas
↑ Back to track
NestJS, zero to senior NEST · 01 · 03

Lifecycle hooks and graceful shutdown

Nest fires lifecycle hooks bottom-up on init and reverse on shutdown — but shutdown hooks are OFF until you call enableShutdownHooks(). Skip it and SIGTERM kills the process instantly: pools severed mid-query, consumers never commit. Drain under 30s.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The deploy looked clean. Kubernetes scaled up the new ReplicaSet, sent the old pods a SIGTERM, and the dashboard went green. Then support tickets: a trickle of Connection terminated unexpectedly and ECONNRESET on checkout, plus a queue of Kafka messages being reprocessed for no reason. We had no error in the new pods — the damage was in the old ones, in the half-second between SIGTERM and process exit. Node received the signal and exited immediately, because nobody had called app.enableShutdownHooks(). The pg pool’s open connections were severed mid-query, in-flight HTTP requests were dropped on the floor, and the Kafka consumer never committed its offsets, so on the next poll those messages looked unhandled. Every clean-up our code carefully wrote in onModuleDestroy never ran. This lesson is about the lifecycle Nest gives you, and the one line that decides whether shutdown is graceful or a guillotine.

Init: bottom-up, child modules before parents

When you ask “can I start a poller in onModuleInit?” or “why did my sibling module’s connection look unready?”, the answer comes down to the boot order. Get it right and your startup is deterministic; get it wrong and you fire into a half-initialized graph.

Nest boots in two passes. First it constructs the whole DI graph — every provider that L02 taught you about. Then it walks the modules and fires the init hooks. Two hooks matter here, and the order between them is the thing people get wrong.

onModuleInit() fires per module, as soon as that module’s own providers are resolved — and children (imported modules) fire before their parents. So a DatabaseModule you import runs its onModuleInit before the UsersModule that imports it. onApplicationBootstrap() fires once, after every module in the app has finished initializing.

import { Injectable, OnModuleInit, OnApplicationBootstrap } from '@nestjs/common';

@Injectable()
export class IndexerService implements OnModuleInit, OnApplicationBootstrap {
  // runs when THIS module's providers are ready — children before parents.
  // Hooks may be async; Nest awaits them before moving on.
  async onModuleInit() {
    await this.warmLocalCache(); // only needs THIS module's deps
  }

  // runs after the ENTIRE app graph is live — safe to touch other modules.
  async onApplicationBootstrap() {
    this.startPoller(); // depends on a sibling module being initialized too
  }
}

The distinction is not cosmetic. In onModuleInit of module A, the only thing guaranteed ready is A and the modules A imports. A sibling module B you reach across to — say a message bus you publish to — may not have run its own onModuleInit yet. Start a poller there and it can fire into a half-initialized graph.

Why this works

Why start the poller in onApplicationBootstrap and not onModuleInit? Because onModuleInit only guarantees this module and its direct imports are ready — Nest fires it the instant a module’s own providers resolve, which can be long before sibling modules you depend on cross-cuttingly have initialized. A poller, a cron, a “publish a startup event” — anything that reaches across the module graph — needs the whole app live. onApplicationBootstrap is the single point where Nest guarantees every module has finished onModuleInit. Use onModuleInit for self-contained warm-up (this module’s cache, this module’s connection check); use onApplicationBootstrap the moment the work touches another part of the graph.

Shutdown: reverse order, and OFF by default

Shutdown runs the lifecycle backwards. The order is onModuleDestroy()beforeApplicationShutdown(signal?)onApplicationShutdown(signal?), and modules tear down in reverse of init: dependents before dependencies. That ordering is deliberate — a module gets to clean up before the modules it depends on disappear, so your UsersModule can flush before the DatabaseModule it builds on closes its pool. The last two hooks receive the signal string ('SIGTERM', 'SIGINT') so you can branch on why you’re shutting down.

import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { Pool } from 'pg';

@Injectable()
export class Database implements OnModuleDestroy {
  private pool = new Pool(/* ... */);

  // Nest AWAITS this on shutdown — drain in-flight, then close cleanly.
  async onModuleDestroy() {
    await this.pool.end(); // waits for active queries, then releases sockets
    // for a broker: await this.consumer.disconnect() — commit offsets, unsubscribe
  }
}

Here is the trap that produced the incident. None of these hooks run by default. Nest does not listen for OS termination signals unless you opt in — because registering process-level signal listeners has a small per-listener cost, and the framework refuses to impose it on every app silently. You enable it with one line in main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // WITHOUT this line, SIGTERM exits the process immediately and NO
  // onModuleDestroy / onApplicationShutdown ever runs — pools severed,
  // requests dropped, broker offsets uncommitted.
  app.enableShutdownHooks();

  await app.listen(3000);
}
bootstrap();

Forget that line and SIGTERM behaves like kill -9 as far as your cleanup is concerned: the event loop never gets a chance to run your pool.end(), the consumer never disconnects, and Kubernetes happily reports a successful rollout while the old pods leak half-finished work.

The two failure modes: no drain, and a drain that never ends

The first failure mode is the incident above: no enableShutdownHooks(), so SIGTERM truncates everything. The fix is the one line plus real cleanup in onModuleDestroyawait pool.end(), await consumer.disconnect() — so pools drain and the broker commits before the process leaves.

But there’s a symmetric second failure mode, and seniors hit it after they enable hooks: a shutdown hook that hangs. If onModuleDestroy awaits a promise that never resolves, or you write a 60-second drain, Kubernetes doesn’t wait forever. It waits terminationGracePeriodSecondsdefault 30 seconds — then sends SIGKILL and truncates your cleanup anyway. A hook that blocks past the grace period is no safer than no hook at all; it just fails later and more confusingly. So the drain has to finish comfortably under 30 seconds: a sane budget is 5–15 seconds. And draining requests isn’t enough on its own — you also need the load balancer to stop sending new ones before you stop accepting. Flip the readiness probe to unready on SIGTERM and add a preStop sleep of about 5 seconds so endpoint deregistration propagates through kube-proxy before the app actually goes down. Otherwise you drain the old requests perfectly and then drop the new ones that arrived during the gap.

Pick the best fit

You run a Nest service on Kubernetes with a pg pool and a Kafka consumer. A rolling deploy SIGTERMs the old pods. How do you make the service drain cleanly without dropping requests or losing broker offsets?

Quiz

Your providers implement onModuleDestroy to close a pg pool, but main.ts never calls app.enableShutdownHooks(). The pod receives SIGTERM. What runs?

Quiz

You need to start a background poller that publishes to a message bus owned by a SIBLING module. Which lifecycle hook is correct, and why?

Recall before you leave
  1. 01
    Walk the full Nest lifecycle: which init hooks fire and in what order, which shutdown hooks fire and in what order, and what gates the shutdown side from running at all.
  2. 02
    A Kubernetes rolling deploy SIGTERMs your Nest pod. Describe both ways shutdown goes wrong and the complete fix, with the real numbers.
Recap

Nest runs your providers through a lifecycle in two passes. On init it builds the whole DI graph, then fires onModuleInit() per module — children before parents, as soon as a module’s own providers resolve — and onApplicationBootstrap() once, after every module has initialized; use onModuleInit for self-contained warm-up and onApplicationBootstrap for anything that reaches a sibling module, like starting a poller, because onModuleInit only guarantees this module and its imports are ready. On shutdown the order reverses: onModuleDestroy() → beforeApplicationShutdown(signal?) → onApplicationShutdown(signal?), tearing down dependents before dependencies so a module cleans up before its deps vanish, with the signal string passed to the last two. The decisive catch is that shutdown hooks are OFF until you call app.enableShutdownHooks() in main.ts — Nest won’t register OS signal listeners by default because each has a small cost — so without that one line SIGTERM exits the process immediately and no cleanup runs: the pg pool is severed mid-query (Connection terminated unexpectedly / ECONNRESET), in-flight requests are dropped, and the Kafka consumer never commits offsets. The fix is the line plus real cleanup in onModuleDestroy (await pool.end(), await consumer.disconnect()). The symmetric trap is a hook that hangs: Kubernetes waits only terminationGracePeriodSeconds (default 30s) then SIGKILLs, so the drain must finish well under it (budget 5–15s), and you must also flip the readiness probe to unready and add a ~5s preStop sleep so the load balancer stops routing before you stop accepting — otherwise you drain old requests and drop the new ones that arrived during the gap. Now when you see ECONNRESET errors or Kafka message reprocessing after a rolling deploy, check main.ts for enableShutdownHooks() first — that single missing line is the root cause more often than anything else.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.