Profile Node.js startup one module load at a time.
coldstart is a zero-dependency startup profiler for Node.js that instruments CommonJS and ESM startup loading, reconstructs the dependency tree, and points at the modules that actually slow boot time down.
Terminal tree, JSON, and self-contained flamegraph output are included.
It is designed for questions like:
- Why did startup go from
300msto900ms? - Which dependency chain is dominating boot?
- Is the time going into my code or
node_modules? - Which modules are slow themselves vs slow only because of their children?
- Is startup blocking the event loop?
- Times every CommonJS
require()withperformance.now() - Traces ESM startup loads through Node's loader hooks
- Builds a parent → child module load tree
- Computes inclusive and exclusive time per module
- Tracks builtins like
fs,path, andhttp - Measures event loop delay during startup with
perf_hooks - Ships a terminal report, JSON report, and self-contained HTML flamegraph
- Has no runtime dependencies
What works today:
- CommonJS startup profiling
- ESM startup profiling
- CLI profiling
- programmatic API
- text report
- JSON report
- single-file flamegraph HTML export
What does not work yet:
- dynamic
import()tracing
- Node.js
18+ - Node.js
18.19+for ESM tracing through the loader hook path
npm install coldstartIf you are working in this repository directly:
npm install
npm run buildProfile a Node app:
coldstart server.jsProfile an ESM entry:
coldstart server.mjsPass Node flags through -- node ...:
coldstart -- node --trace-warnings server.jsPrint JSON instead of the text tree:
coldstart --json server.jsIf you are running from this repo before publishing:
node dist/cli.js server.jsimport { monitor, renderTextReport } from 'coldstart'
const done = monitor()
require('./bootstrap')
require('./server')
const report = done()
console.log(renderTextReport(report))If you want just the loader patch:
node --require coldstart/register server.jsFor ESM or mixed apps, prefer:
node --import coldstart/register server.mjsFrom this repo:
node --require ./dist/register.js server.jsnode --import ./dist/register.mjs server.mjsFor ESM:
node dist/cli.js -- node --input-type=module -e "await import('./dist/index.mjs')"Or:
node dist/cli.js -- node -e "require('express'); require('sequelize')"If all you test is fs, path, or http, seeing 0ms is expected.
coldstart — 847ms total startup
┌─ express 234ms ████████████░░░░░░░░
│ ├─ body-parser 89ms █████░░░░░░░░░░░░░░░
│ ├─ qs 12ms █░░░░░░░░░░░░░░░░░░░
│ └─ path-to-regex 8ms ░░░░░░░░░░░░░░░░░░░░
├─ sequelize 401ms █████████████████████ ⚠ slow
│ ├─ pg 203ms ███████████░░░░░░░░░
│ └─ lodash 98ms █████░░░░░░░░░░░░░░░
└─ dotenv 4ms ░░░░░░░░░░░░░░░░░░░░
event loop max 42ms, p99 17ms, mean 4.3ms
modules 312 total, 59 cached
time split 286ms first-party, 503ms node_modules
Color thresholds in the text reporter:
- green: under
20ms - yellow:
20msto100ms - red: over
100ms
coldstart [--json] [--color] [--no-color] app.js [args...]
coldstart [--json] [--color] [--no-color] -- node [node-flags...] app.js [args...]Examples:
coldstart app.js
coldstart app.js --port 3000
coldstart --json app.js
coldstart -- node --inspect app.jsWhat the CLI does:
- spawns your app in a child Node process
- preloads
coldstart/register - lets the app run normally
- collects the startup report on exit
- prints either text or JSON
Starts a fresh measurement and returns a function that finalizes the report.
import { monitor } from 'coldstart'
const done = monitor()
const report = done()Returns the current report immediately.
import { report } from 'coldstart'
const startupReport = report()This is mainly useful if you already enabled the hook separately.
Returns the terminal report as a string.
import { renderTextReport } from 'coldstart'
console.log(renderTextReport(report))Options:
color?: booleanbarWidth?: numbershowSummary?: boolean
Returns the report as JSON.
import { renderJsonReport } from 'coldstart'
process.stdout.write(renderJsonReport(report))Options:
pretty?: boolean
Returns a self-contained HTML flamegraph.
import { renderFlamegraphHtml } from 'coldstart'
import { writeFileSync } from 'fs'
writeFileSync('coldstart.html', renderFlamegraphHtml(report))Open the generated file in a browser to inspect the startup timeline visually.
coldstart records raw startup load events from both the CommonJS loader and the ESM loader hook path.
For CommonJS:
- the request is resolved
- the parent module is tracked
- wall-clock load time is measured with
performance.now() - cache hits are recorded
- builtins are recorded as dependency edges
For ESM:
- module resolution is captured through the loader
resolvehook - module execution completion is tracked through the loader
loadhook - timing is bridged back to the main tracer through a message channel
After startup, the tracer converts those raw events into:
- a tree of module loads
- a flat list sorted by inclusive time
- a slowest list sorted by exclusive time
- summary stats for total startup, cached loads, event loop blocking, first-party time, and
node_modulestime
Each module node includes both inclusiveMs and exclusiveMs.
Use inclusiveMs to answer:
- Which subtree made startup slow?
- Which dependency chain dominates boot time?
Use exclusiveMs to answer:
- Which module itself is slow?
- Where is the actual initialization work happening?
Rule of thumb:
- high inclusive, low exclusive: the module mostly pulls in slow children
- high exclusive: the module itself is expensive
interface StartupReport {
totalStartupMs: number
eventLoop: {
maxBlockMs: number
meanBlockMs: number
p99BlockMs: number
}
tree: ModuleNode[]
flat: ModuleNode[]
slowest: ModuleNode[]
nodeModuleTime: number
firstPartyTime: number
totalModulesLoaded: number
cachedModulesCount: number
}interface ModuleNode {
id: string
request: string
resolvedPath: string
parentPath: string
inclusiveMs: number
exclusiveMs: number
cached: boolean
isNodeModule: boolean
isBuiltin: boolean
children: ModuleNode[]
depth: number
}Because builtins are not loaded from disk like normal package files. They are still useful to record as dependency edges, but their measured cost is usually effectively zero.
Your test is probably too small. Try profiling real modules instead:
coldstart -- node -e "require('typescript')"or:
coldstart app.jswhere app.js loads real package or first-party code.
Make sure you are on Node 18.19+. The ESM path depends on module.register() and Node's loader hooks.
Also note that current support is for startup ESM loading. Dynamic import() tracing is still not implemented.
The CLI prints the report when the child process exits. It is intended for startup profiling runs, not long-lived always-on monitoring.
If you need more control, use the programmatic API and decide when to call done().
Because cached loads still describe the startup dependency graph. In text output, noisy zero-cost cached builtin rows are collapsed to keep the tree readable.
Build:
npm run buildType-check:
npx tsc -p tsconfig.jsonSmoke test:
node dist/cli.js -- node -e "require('typescript')"ESM smoke test:
node dist/cli.js -- node --input-type=module -e "await import('./dist/index.mjs')"Programmatic smoke test:
node -e "const { monitor, renderTextReport } = require('./dist/index.js'); const done = monitor(); require('typescript'); console.log(renderTextReport(done()));"Startup regressions are easy to introduce and easy to miss.
A single heavy dependency, synchronous work in module scope, or a deep require chain can quietly add hundreds of milliseconds before your app is ready. coldstart makes that visible fast.
MIT