NestJS 12 Lifecycle Hooks: The Order You Can't Assume
NestJS 12 changed how lifecycle hooks are ordered. Learn what onModuleInit and onApplicationBootstrap really guarantee, plus a copy-paste cheat sheet.

A NestJS app upgrades from v11 to v12. No application code changes — just npm i @nestjs/core@latest @nestjs/common@latest. The test suite, which has been green for months, starts failing intermittently: a report service reads a cache that a metrics service is supposed to have already populated, and about one run in three, it hasn't been.
Nothing in the diff touches either service. The bug was always there — it just used to be invisible, because the old, undocumented order happened to keep working. The official v12.0.0 release notes list the cause in one line, under breaking changes: "Lifecycle hooks are now invoked by component hierarchy level." The fix isn't a workaround for v12 — it's realizing that "hook order" was never a promise Nest made for providers like these two.
By the end of this article you'll be able to:
- Name every NestJS lifecycle hook and state precisely when each one fires
- Explain which part of hook ordering Nest actually guarantees — and which part it has always reserved the right to change
- Recognize the v12 breaking change for what it is, and know why it broke code that looked correct
- Fix a hook-ordering bug the right way: with dependency injection, not a delay or a flag
- Enable and reason about shutdown hooks (
onModuleDestroy,beforeApplicationShutdown,onApplicationShutdown) correctly
You've written at least one onModuleInit() in a NestJS service and injected a provider into another provider's constructor. You don't need any prior lifecycle-hook experience beyond that — this article builds the full model from there.
This article is written against NestJS 12.x (verified against @nestjs/core's npm dist-tags and the nestjs/nest GitHub release history: latest is 12.2.0, v12.0.0 shipped August 27, 2026, and the 11.x line has moved to the legacy npm tag at 11.2.7). The point releases since v12.0.0 (through 12.2.0) fix shutdown-sequence and startup-failure edge cases but don't change the onModuleInit/onApplicationBootstrap ordering model described below. Everything here is core-container behavior — it works identically whether your HttpAdapter is Express or Fastify, since lifecycle hooks fire before Nest ever hands a request to either one.
- The problem: a hook order nobody promised
- The mental model: constructed order vs. hook order
- Stage 1: the five lifecycle hooks
- Stage 2: the order Nest does guarantee
- Stage 3: the order it never guaranteed — and v12 changed
- Stage 4: the fix is DI, not a delay
- Stage 5: shutdown hooks and async hooks
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
Here's the setup, trimmed to the part that matters:
ReportsService never injects MetricsService. There's no constructor(private metrics: MetricsService) anywhere — the two talk through a module-scope variable instead, and the code "works" as long as MetricsService's onModuleInit happens to run before ReportsService's. Under Nest 10 and 11, it reliably did — because hook order followed each module's position in AppModule's imports array, and MetricsModule happened to be listed first.
It is not a coincidence that this looks fragile. It is fragile — it was fragile before the upgrade too. The v12 release note doesn't introduce the bug; it removes the accident that was hiding it. Once hook invocation moved to being ordered "by component hierarchy level" instead of import position, ReportsService.onModuleInit can run first, and requestCounterSeed is still 0 when it reads it.
The mental model: NestJS gives you exactly one hard guarantee about initialization order, and it comes from the dependency graph, not from where a hook happens to live. If provider B is constructor-injected into provider A, Nest must fully construct B — and run B's own onModuleInit — before it can finish constructing A and run A's onModuleInit. That ordering is real, structural, and the same in every version, because it falls directly out of how constructor injection works: you cannot hand A a B that doesn't exist yet.
Everything else — the order of two providers that share no dependency edge, sitting in sibling modules that never import each other — has never been part of that contract. It happened to be stable for a long time because Nest's internal bootstrap walked the module tree in a specific, observable order. "Observable" is not "guaranteed": nothing in Nest's public API ever promised that two unrelated providers would always initialize in the same relative order, which is exactly why the v12 release notes can change it and call it a breaking change rather than a bug fix — it was breaking an assumption, not a contract.
Key concept: if two providers' initialization order matters to your code, that requirement has to be expressed as a dependency — an injected constructor parameter — not inferred from import position. If there's no edge in the graph, there's no promise about the order.
Runs right in your browser — poke at it and watch the concept react live.
Nest exposes five lifecycle interfaces a provider (or module class) can implement. Each is a plain TypeScript interface with one method, and implementing it is how you opt in:
onModuleInit()— called once the host module's own dependencies have been resolved and constructed. This is the hook for "set up something that needs this provider's injected dependencies to already exist."onApplicationBootstrap()— called once every module in the app has finishedonModuleInit, but before Nest starts listening for connections. Use it for work that should only start once the whole graph is up, not just this provider's own corner of it.onModuleDestroy()— the mirror ofonModuleInit, run during teardown.beforeApplicationShutdown(signal?: string)— runs afteronModuleDestroy, with access to the OS signal (SIGTERM,SIGINT) that triggered shutdown, if there was one.onApplicationShutdown(signal?: string)— the last hook to run, immediately before the process would otherwise exit.
onModuleInit and onApplicationBootstrap only fire if you actually call app.init() or app.listen() — a TestingModule you only compile() but never init() skips both, which is a common source of "why didn't my hook run in this test" confusion.
The one ordering rule worth memorizing: a provider's onModuleInit runs after the onModuleInit of every provider it depends on, transitively. This is a direct consequence of constructor injection being resolved depth-first.
Because OrdersService injects CacheService, "CacheService ready" is guaranteed to print before "OrdersService ready" — in Nest 10, 11, 12, and every future version, because changing it would mean handing OrdersService a CacheService whose own setup hasn't run yet. That's not an implementation detail Nest could casually break; it's the definition of what constructor injection means.
onApplicationBootstrap extends the same guarantee one level further: because it only fires after every module's onModuleInit has completed, anything you do there can safely assume the entire app's onModuleInit work — dependency-ordered or not — is already done.
What Nest has never promised is the relative order between two providers that share no dependency edge — like MetricsService and ReportsService above. Before v12, that order was a side effect of how Nest walked the module tree (roughly, each module's imports in declaration order, depth-first). It was deterministic in practice, which is exactly what made it easy to depend on by accident.
The v12.0.0 release notes list the actual change under breaking changes, verbatim: "Lifecycle hooks are now invoked by component hierarchy level." The notes don't spell out the full new algorithm step by step — their own guidance is to "review ordering assumptions between related providers/modules in init, teardown, and tests" rather than to assume your old mental model of "import order" still predicts anything. That's the honest, practical takeaway: don't try to reverse-engineer the new implicit order and depend on that instead — it's exactly the same mistake with a different answer.
The wrong fixes are tempting and all fragile: reordering imports in AppModule (back to relying on an undocumented detail, just a different one), adding a setTimeout before reading the shared state (a race condition with extra steps), or introducing a global "ready" flag with a polling loop. All three fix today's symptom and leave the actual defect — an implicit dependency — in place.
The real fix is to make the dependency explicit, the same way you would for any other cross-service call:
Once ReportsService constructor-injects MetricsService, Stage 2's guarantee applies again: Nest cannot construct ReportsService without MetricsService already existing, and MetricsService.onModuleInit is guaranteed to have already run. The module-scope variable is gone, replaced by a method call on an instance Nest already knows how to order correctly — because now there's an edge in the graph for it to order by.
Key concept: a lifecycle-ordering bug is almost always a missing edge in the dependency graph wearing a disguise. Add the edge (inject the thing), and the ordering problem disappears on its own — you don't have to solve it, Nest already does.
Every hook in Stage 1 can be async — return a Promise, and Nest waits for it before moving to the next stage. An async onModuleInit() that awaits a database connection genuinely blocks app.listen() until it resolves, which is usually exactly what you want (don't accept traffic before you can serve it) but is worth knowing explicitly, since a slow connection now shows up as a slow boot, not a slow first request.
Teardown hooks (onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown) only run on app.close() or an OS shutdown signal — and only if you've opted in:
Forgetting enableShutdownHooks() is the most common reason a service that's supposed to flush a queue or close a connection pool on shutdown silently doesn't — the process just exits, and no teardown hook ever runs.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- Tests that only
compile(), neverinit().Test.createTestingModule({...}).compile()builds the module graph but never callsonModuleInitoronApplicationBootstrap. If a hook-driven side effect doesn't appear to run in a test, check whether the test ever called.init()on the result. - Circular dependencies with
forwardRef(). Two providers that depend on each other viaforwardRef(() => OtherService)can't have a clean "B before A" order, because there is no clean order — Nest resolves the cycle, but a hook in either one that assumes the other is fully initialized can still race. Prefer breaking the cycle over patching around it. - Global modules aren't a shortcut around ordering. A module marked
@Global()is visible everywhere without importing it, but it is still initialized according to the same rules — "global" changes visibility, not hook order. onApplicationBootstraprunning late in a slow-starting app. Because it waits for every module'sonModuleInit, one slow async hook anywhere in the graph delaysonApplicationBootstrapfor the whole app, not just its own module.- Upgrading from 11.x to 12.x with hidden cross-module hook assumptions. This is the v12-specific case this article opened with. If a test suite starts flaking right after the upgrade, treat it as a real latent bug the version bump exposed, not a Nest regression to work around.
- Never let a hook in one provider assume another unrelated provider already ran unless that assumption is backed by a real constructor-injection edge. If it isn't, add the edge.
- Use
onApplicationBootstrapfor anything that needs the whole app ready, not just its own module's dependencies — don't reach for a hack to delayonModuleInitwork instead. - Call
app.enableShutdownHooks()in production if any provider relies ononModuleDestroyoronApplicationShutdownto flush state or close connections cleanly. - When you upgrade a major Nest version, read the release's breaking-changes list before you read anything else — a change like this one produces no compile error and no obvious runtime error, only an intermittent one.
- If you genuinely need to verify ordering empirically, log each hook with its provider's name during boot rather than assuming either the "old" or "new" order from memory — the release notes don't spell out the full new algorithm, so the only way to know your app's actual order is to look at it.
Only where a real dependency edge exists. If A injects B, B's onModuleInit is guaranteed to run first. Between providers with no such edge, there's no guaranteed order, and NestJS 12's release notes confirm that relative order can and did change.
Almost always because app.enableShutdownHooks() was never called. Without it, Nest doesn't attach the OS signal listeners that trigger the teardown hooks at all.
Yes. Nest awaits every onModuleInit (and then every onApplicationBootstrap) before it starts accepting connections, so a slow one delays the whole app's startup, not just the part that's slow.
No. Lifecycle hooks fire during module bootstrap, entirely before Nest hands anything to the underlying HTTP adapter, so the behavior is identical regardless of which one you're using.
There's no way to know in advance, and that uncertainty is the actual point of this article: code that depends only on guaranteed ordering (real dependency edges) can't be broken by a future reshuffle of the unguaranteed part, because it never relied on it.
| Hook | Fires when | Guaranteed relative to |
|---|---|---|
onModuleInit() | Host module's own dependencies constructed | After every injected dependency's onModuleInit |
onApplicationBootstrap() | Every module's onModuleInit has finished | After all onModuleInit hooks app-wide |
onModuleDestroy() | app.close() or shutdown signal (needs enableShutdownHooks()) | Mirrors onModuleInit's dependency order, in reverse |
beforeApplicationShutdown(signal) | After all onModuleDestroy hooks | After every onModuleDestroy |
onApplicationShutdown(signal) | Last hook before process exit | After beforeApplicationShutdown |
| Two providers, no dependency edge | — | Not guaranteed, by design — don't rely on it |
| Opt in to teardown hooks | app.enableShutdownHooks() in main.ts | Must be called explicitly |
| Fix an ordering bug | Constructor-inject the provider you actually depend on | Turns an assumption into a real graph edge |
- NestJS guarantees hook order along the dependency graph: if
AinjectsB,B's hooks always run first. That rule has never changed and never will, because it's a consequence of constructor injection, not a scheduling choice. - It has never guaranteed order between providers with no dependency edge — that was always an implementation detail, and NestJS 12's release notes confirm it changed.
- A hook-ordering bug is a missing dependency edge in disguise. The fix is to inject the thing you depend on, not to chase the new implicit order.
- Teardown hooks need
app.enableShutdownHooks()to run at all — a very common silent gap.
The report service from the top of this article didn't start being wrong the day it was upgraded to Nest 12 — it was wrong from the day someone reached for a module-scope variable instead of a constructor parameter. The version bump just stopped covering for it. Once you're reading "hook order" as "the dependency graph, and nothing else," that whole bug class stops being a mystery you debug after an upgrade and starts being something you can rule out on sight.
Has a major-version upgrade ever exposed an ordering assumption you didn't know your code was making? Drop it in the comments.
- NestJS Dependency Injection Explained (with Cheat Sheet)
- NestJS Testing Module: Provider Overrides (with Cheat Sheet)
- NestJS Module Encapsulation Explained (with Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.