Boot trace
The module graph can overlay how long every provider and controller took to
construct during one real boot. A slow constructor or onModuleInit stops
being a guess and becomes a bar with a number on it.
Why it needs a change to your code
nestjs-doctor is a scanner that reads source files. It never runs your application, so construction time does not exist for it to measure.
NestJS measures it already. Booting with { snapshot: true } records an
initTime for every class and assembles a SerializedGraph. Writing that
graph to disk takes one change to main.ts, and --timings reads it back.
Let an agent do it
The change below is mechanical, and an agent can make it:
npx nestjs-doctor@latest --initThat installs a nestjs-boot-trace skill. Ask for a boot trace and the agent
edits main.ts, boots once, runs the scan, reads the cascade, and puts
main.ts back. It works in Claude Code, Cursor, Codex, and the other agents on
Coding agents.
The rest of this page is the same work by hand.
Capture the dump
Two versions gate what you get:
- Class construction times:
@nestjs/core9.3.9 or newer, which addedinitTime. - Lifecycle hook times: Nest 11.1.4 or newer, which added the
instrumentoption.
Pick your version. The right column is what src/main.ts becomes, and it is
development-only either way:
import { NestFactory } from "@nestjs/core";import { AppModule } from "./app.module";const app = await NestFactory.create(AppModule);await app.listen(3000);import { writeFileSync } from "node:fs";import { performance } from "node:perf_hooks";import { NestFactory, SerializedGraph } from "@nestjs/core";import { AppModule } from "./app.module";const t0 = performance.now();const app = await NestFactory.create(AppModule, { snapshot: true });const createMs = performance.now() - t0;await app.init();const initMs = performance.now() - t0;await app.listen(3000);const startupMs = performance.now() - t0;const graph = JSON.parse(app.get(SerializedGraph).toString());Object.assign(graph, { createMs, initMs, startupMs });writeFileSync("nestjs-doctor-timings.json", JSON.stringify(graph));snapshot: true is the part that makes NestJS record initTime. The three
performance.now() markers become the lifecycle strip. On 11.1.4 and newer,
instanceDecorator wraps each instance's hooks so their durations land in
hookTimings.
That decorator replaces a method on every instance in the application. Keep it out of production behind an environment check or a separate entry point.
Boot the app once, then scan:
npx nestjs-doctor@latest . --report --timings nestjs-doctor-timings.jsonRelative paths resolve against the scanned directory. Without --report the
flag is ignored, with a warning.
Read a time
Each class's time includes waiting on its own dependencies. A shared slow dependency therefore counts again in every class that awaits it.
Read down a cascade until the number drops. The class where it drops owns the
time. If UsersService reads 120ms and the SlowService it injects reads
119ms, SlowService owns it.
A module node shows two numbers. The build is the own construction of its
slowest class, after that class's dependencies, never a sum across classes.
The hook time is the total its classes spent in lifecycle hooks, for example
104ms build · 63ms init. A package node keeps its package line and shows
its time in the tooltip.
What the report shows
The Boot trace tab is one waterfall on one absolute axis, read the way an APM trace is. The label column lists each module and its classes. The lanes hold the lifecycle phases, the viewport window, and the time axis; each bar sits at its real offset from boot start.
| Element | Where | Means |
|---|---|---|
| Overview lane | Top of the lanes | building modules · lifecycle hooks · opening the port, from the createMs, initMs, and startupMs markers; with moduleInitMs the hooks split into init hooks · bootstrap hooks. The window on top is the view: drag it to pan, drag its edges to resize, scroll to zoom, click a phase to frame it, hover one for its name and time. A phase nothing ran inside draws as a black weave; when bars cover less than a phase, its label adds · Nms in classes. Coincident markers make a 0ms phase, kept as a narrow woven column; clicking it frames the boundary. The whole timeline shares one scale: where a 0ms or sub-millisecond column is widened to stay readable, the axis, the guides, and the bars widen with it, and the axis marks that stretch with the same dashed weave. Every label is still a real time; the distance between two of them is not always to scale |
| Dotted line | Down the rows | Where one phase hands over to the next; a woven stripe where the markers coincide, echoing the lane's 0ms column |
| Group header | Above its classes | The module that owns them in the dump, their count, and an external or ambiguous tag when the graph has no single node for it |
| Class bar | Per row, under its module's header | From its slowest dependency's finish to its own, or from boot start when nothing traced came before; colored by type. The time it took sits inside the bar |
| Hook span | On a row, at its real offset | An onModuleInit or onApplicationBootstrap, time inside; one span per run, so a transient provider shows one per instance. A hook the dump gives no offset counts only in its module's totals |
| Cascade row | Under an opened chevron, solid black | A shadow of the dependency's own row in its module group, where its cost is drawn once. deduped says so, as npm ls would. shared means it was built earlier for another consumer, circular closes a loop to an ancestor |
The chevron before a class opens its dependencies in place; expand all in the header opens every level at once. Hovering a bar or a hook span shows a card with the class, what it waited on, its module and type, and its time. For a package module that is not global it also names the importing module, when exactly one module imports that instance.
The label column drags to resize and hides like the graph's sidebar. The Modules graph keeps a compact mount of the same timeline in its dock. The trace button in a module's detail panel opens the tab on that module's slowest class.
Multiple entry points
A monorepo boots more than once. Pass one dump per entry point, each optionally labelled:
npx nestjs-doctor@latest . --report --timings api.json,worker=apps/worker/dump.jsonEvery dump becomes its own trace with a picker on the Boot tab, and the report matches each one to a project by its label, its root module, or the modules only that project owns. In the modules graph, the dock shows the selected module's own trace; a module whose app was never booted says so instead of borrowing another trace.
Limits
- Timings are display-only. They never affect the score, the diagnostics, or the exit code.
- An unreadable, malformed, or unrecognized dump degrades to a stderr warning, and the report renders without timings.
- Out-of-order phase markers drop the lifecycle strip, also with a warning.
- Bars sit on Nest's load clock, which starts after the module scan: the
first stretch of
building moduleshas no bars under it. - Middleware is constructed during
app.init(), not the build phase, so it has no bar. - A controller or injectable is clocked from its module's later load start; its bar draws after its slowest dependency instead.
- A class joins a graph module only when that module's class name is unique,
inside the dump and across a monorepo's projects. Otherwise it sits under
the name the dump gives it. A package module such as
TypeOrmModuleshows anexternaltag; a user module whose name repeats shows anambiguoustag. Every instance with the same name lands in one group.