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 --init

That 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/core 9.3.9 or newer, which added initTime.
  • Lifecycle hook times: Nest 11.1.4 or newer, which added the instrument option.

Pick your version. The right column is what src/main.ts becomes, and it is development-only either way:

main.ts
1-import { NestFactory } from "@nestjs/core";
2import { AppModule } from "./app.module";
3
4-const app = await NestFactory.create(AppModule);
5await app.listen(3000);
main.ts, instrumented
1+import { writeFileSync } from "node:fs";
2+import { performance } from "node:perf_hooks";
3+import { NestFactory, SerializedGraph } from "@nestjs/core";
4import { AppModule } from "./app.module";
5
6+const t0 = performance.now();
7+const app = await NestFactory.create(AppModule, { snapshot: true });
8+const createMs = performance.now() - t0;
9+await app.init();
10+const initMs = performance.now() - t0;
11await app.listen(3000);
12+const startupMs = performance.now() - t0;
13+
14+const graph = JSON.parse(app.get(SerializedGraph).toString());
15+Object.assign(graph, { createMs, initMs, startupMs });
16+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.json

Relative 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.

ElementWhereMeans
Overview laneTop of the lanesbuilding 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 lineDown the rowsWhere one phase hands over to the next; a woven stripe where the markers coincide, echoing the lane's 0ms column
Group headerAbove its classesThe 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 barPer row, under its module's headerFrom 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 spanOn a row, at its real offsetAn 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 rowUnder an opened chevron, solid blackA 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.json

Every 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 modules has 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 TypeOrmModule shows an external tag; a user module whose name repeats shows an ambiguous tag. Every instance with the same name lands in one group.