CLI tool to analyze V8 .cpuprofile files and print the top functions by self-time or total-time directly in the terminal. Built for AI agents and humans who want quick profiling insights without opening Chrome DevTools.
npm install -g profanoOr run on demand:
npx profano profile.cpuprofileprofano ships with a skill file that teaches AI coding agents when and how to use it. Install it with:
npx -y skills add remorses/profanoThis installs skills for AI coding agents like Claude Code, Cursor, Windsurf, and others. The skill lives at skills/profano/SKILL.md in this repo.
# Analyze a profile, sorted by self-time (default)
profano ./tmp/cpu-profiles/CPU.*.cpuprofile
# Sort by total/inclusive time instead
profano profile.cpuprofile --sort total
# Show the top 50 functions (default 30)
profano profile.cpuprofile -n 50Globs are expanded automatically, so you can pass multiple profiles at once.
Always run profano --help to see all options and more examples.
Duration: 12.34s
Samples: 11542 active / 12340 total (6.4% idle)
Sort: self
Self %Self Self ms Total %Total Total ms Function Location
─────── ────── ─────── ─────── ────── ──────── ────────────────────────────────────────── ────────────────────────────────
3402 29.5% 3.40s 6804 58.9% 6.80s parseAsync src/parser.ts:142
...
- Self / Self ms — samples and time where the function was at the top of the stack (exclusive time).
- Total / Total ms — samples and time where the function appeared anywhere in the stack (inclusive time).
- %Self / %Total — percent of non-idle active samples, so you can see real hotspots even if the profile is mostly idle.
Idle, GC, and VM pseudo-frames ((idle), (garbage collector), (program), (root)) are excluded from the function list so they don't drown out real code.
Start with --sort self to find CPU-bound leaves (hot inner functions). Switch to --sort total to find expensive callers that dominate wall time.
Node has a built-in CPU profiler. Pass --cpu-prof when running a script and it writes a .cpuprofile file to the --cpu-prof-dir directory on exit:
node --cpu-prof --cpu-prof-dir=./tmp/cpu-profiles ./script.js
profano ./tmp/cpu-profiles/CPU.*.cpuprofileNode writes the profile on normal process exit. You do not need a custom signal handler — Node flushes the profile automatically when the process exits on SIGINT (Ctrl+C) or SIGTERM (kill <pid>). SIGKILL (kill -9) skips the flush because the kernel terminates the process before Node gets a chance to run, so never use kill -9 on a process you want to profile.
Anything that ultimately spawns node respects the NODE_OPTIONS env var. Use it when you cannot pass --cpu-prof directly to node — for example pnpm dev, vite, tsx, next dev, nest start, a package.json script, or a CI step:
NODE_OPTIONS="--cpu-prof --cpu-prof-dir=./tmp/cpu-profiles" pnpm dev
# reproduce the slow path...
# then Ctrl+C
profano ./tmp/cpu-profiles/CPU.*.cpuprofileThis also works for vite, vite build, tsx script.ts, next dev, nest start, etc. Every child node process inherits NODE_OPTIONS, so if the wrapper spawns multiple Node workers each one gets its own CPU.<timestamp>.<pid>.*.cpuprofile. Pass them all to profano at once — it renders a separate table per file with a header separator so you can tell which worker was hot.
Vitest's official CPU profiling pattern uses the top-level test.execArgv option together with test.fileParallelism: false so profiles are not interleaved across files. This is pool-agnostic (forks or threads) — --cpu-prof works in both. Wire it behind an env var so you can opt in per-run:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
const cpuProf = process.env.VITEST_CPU_PROF === '1'
export default defineConfig({
test: {
// Serialize test files so profiles are not interleaved.
// This is the officially documented way to profile Vitest.
fileParallelism: cpuProf ? false : undefined,
execArgv: cpuProf
? ['--cpu-prof', '--cpu-prof-dir=tmp/cpu-profiles']
: [],
},
})Then profile a single test file:
VITEST_CPU_PROF=1 pnpm test --run src/some-file.test.ts
profano tmp/cpu-profiles/CPU.*.cpuprofileProfile one file at a time — running the whole suite with profiling enabled generates dozens of overlapping .cpuprofile files and can overload the machine.
Note: the
--profflag does not work withpool: 'threads'due tonode:worker_threadslimitations.--cpu-profis the right choice for profiling Vitest and works with both pools.
Profiling the Vitest main thread. The execArgv above profiles the test workers, not the Vitest main process itself (where Vite plugin transforms and globalSetup run). To profile the main thread, invoke Vitest's entry file under node --cpu-prof directly:
node --cpu-prof --cpu-prof-dir=./tmp/main-profile ./node_modules/vitest/vitest.mjs --run
profano ./tmp/main-profile/CPU.*.cpuprofileUse this when transform/setup time is high and worker-level profiling shows the hot path is outside test execution.
Bun has a built-in V8-compatible CPU profiler with the same --cpu-prof flag as Node:
bun --cpu-prof --cpu-prof-dir=./tmp/cpu-profiles ./script.ts
profano ./tmp/cpu-profiles/CPU.*.cpuprofileFor wrappers that spawn bun (scripts, watchers, bundlers), use the BUN_OPTIONS env var — same idea as NODE_OPTIONS for Node:
BUN_OPTIONS="--cpu-prof --cpu-prof-dir=./tmp/cpu-profiles" bun run dev
# reproduce the slow path...
# then Ctrl+C
profano ./tmp/cpu-profiles/CPU.*.cpuprofileSame signal rules apply as with Node: the profile is written on clean exit, Ctrl+C and kill <pid> (SIGTERM) both work, kill -9 does not.
Bun also supports --cpu-prof-name <filename> for a fixed output name and --cpu-prof-interval <microseconds> to change the sampling rate (default 1000μs).
You can also record CPU profiles from Chrome DevTools (Performance tab → Record → stop → "Save profile…") and feed the exported .cpuprofile file to profano.
You can drive Chrome's V8 CPU profiler over CDP from the shell using playwriter, which makes it scriptable and agent-friendly. playwriter has no dedicated profiling docs or helpers — it simply forwards raw CDP Profiler.* commands — so the steps below are the canonical workflow.
playwriter talks to Chrome through a browser extension that you need to install from the Chrome Web Store. Without it, the CLI cannot connect to any tab.
Install Playwriter MCP from the Chrome Web Store
After installing, pin the extension and click its icon on the tab you want playwriter to control — or launch Chrome with --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture to skip the per-tab approval. See the playwriter skill for the full Chrome flags per OS.
npm install -g playwriter@latest
playwriter skillRead the full skill output before continuing — it covers session isolation, the sandboxed filesystem, and the getCDPSession helper.
Run playwriter session new from the directory you want your profiles written to. The session sandbox captures that directory at creation time and only allows writes inside it (plus /tmp and os.tmpdir()).
playwriter session new
# Session 1 created. Use with: playwriter -s 1 -e "..."playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("http://localhost:3000", { waitUntil: "domcontentloaded" })'Replace the URL with your dev server or any page you want to profile.
Attach a CDP session via playwriter's getCDPSession helper (do not use page.context().newCDPSession() — it does not work through the playwriter relay), then enable and start the profiler.
playwriter -s 1 -e "$(cat <<'EOF'
const cdp = await getCDPSession({ page: state.page })
state.cdp = cdp
await cdp.send('Profiler.enable')
// microseconds — 1000 = 1ms sample interval (default). Lower = finer detail, bigger file.
await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
await cdp.send('Profiler.start')
console.log('profiling started')
EOF
)"Do whatever triggers the code path you want to profile — click a button, scroll, navigate, type into a form. Only the work that happens between Profiler.start and Profiler.stop ends up in the profile.
playwriter -s 1 -e 'await state.page.locator("button").first().click(); await state.page.waitForTimeout(2000)'playwriter -s 1 -e "$(cat <<'EOF'
const { profile } = await state.cdp.send('Profiler.stop')
await state.cdp.send('Profiler.disable')
const fs = require('node:fs')
fs.mkdirSync('./tmp/cpu-profiles', { recursive: true })
const path = `./tmp/cpu-profiles/browser-${Date.now()}.cpuprofile`
fs.writeFileSync(path, JSON.stringify(profile))
console.log('wrote', path, '-', profile.samples.length, 'samples')
EOF
)"The relative ./tmp/cpu-profiles/ path works because the session was created from your project root in step 3.
# hot leaves (default)
profano ./tmp/cpu-profiles/browser-*.cpuprofile
# expensive callers
profano ./tmp/cpu-profiles/browser-*.cpuprofile --sort total -n 20- Session cwd is locked — the playwriter sandbox captures the shell's cwd at session creation and reuses it for all later
-ecalls in that session. If writes to your project fail withEPERM: access outside allowed directories, create a fresh session from your project root. - Use
getCDPSession({ page })— notpage.context().newCDPSession(). Only the playwriter helper is routed through the relay. - Sampling interval is in microseconds —
Profiler.setSamplingInterval({ interval })takes microseconds, not milliseconds. 1000 = 1ms (the default). Drop to 100 for high-resolution micro-profiling, raise to 10000 for long runs where you want a smaller file. - Extension overhead shows up in the profile — CDP's V8 profiler sees every script running in the page's main world, including scripts injected by browser extensions like React DevTools. Profile in an incognito window with extensions disabled to see only your own code.
React 19.2+ exposes React Performance Track entries in development and profiling builds. In development builds, many component render entries are observable as PerformanceMeasure objects with React-specific detail metadata. The observer below captures that measure-based subset — it does not reproduce every entry React shows in DevTools (some use console.timeStamp instead).
Requirements: React 19.2+ in development or profiling build. Production builds don't emit measures. Requires playwriter to control the browser. In profiling builds, components must be wrapped in <Profiler> or React DevTools extension must be installed for full coverage.
Create a playwriter session and navigate to your React app, then install the observer (replace -s 1 with your session ID):
playwriter session new # note the session IDplaywriter -s 1 -e "$(cat <<'EOF'
await state.page.evaluate(() => {
window.__reactMeasures = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.detail?.devtools?.track) continue
window.__reactMeasures.push({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
track: entry.detail.devtools.track,
})
}
})
observer.observe({ type: 'measure', buffered: true })
})
console.log('Observer installed')
EOF
)"React sets detail.devtools.track on every measure it emits. The filter entry.detail?.devtools?.track keeps only React data and excludes unrelated measures from other libraries. A PerformanceObserver is required because performance.getEntriesByType('measure') may miss entries that React clears.
Click around, navigate, toggle themes, type — any React state change triggers component renders that get captured by the observer.
playwriter -s 1 -e "$(cat <<'EOF'
const measures = await state.page.evaluate(() => window.__reactMeasures)
if (!measures.length) { console.log('No React measures captured'); return }
const TICK = 100
const nodes = [
{ id: 1, callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [2] },
{ id: 2, callFrame: { functionName: '(idle)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [] },
]
const nameToId = new Map()
let nextId = 3
for (const m of measures) {
const name = m.name.replace('\u200b', '')
const key = m.track + '::' + name
if (!nameToId.has(key)) {
const id = nextId++
nameToId.set(key, id)
nodes.push({ id, callFrame: { functionName: name, scriptId: String(id), url: m.track, lineNumber: -1, columnNumber: -1 }, children: [] })
nodes[0].children.push(id)
}
}
const sorted = [...measures].sort((a, b) => a.startTime - b.startTime)
const t0 = sorted[0].startTime
const endUs = Math.round((Math.max(...sorted.map(m => m.startTime + m.duration)) - t0) * 1000)
const events = sorted.map(m => ({
startUs: Math.round((m.startTime - t0) * 1000),
endUs: Math.round((m.startTime + m.duration - t0) * 1000),
nodeId: nameToId.get(m.track + '::' + m.name.replace('\u200b', '')),
}))
const samples = []
const timeDeltas = []
for (let t = 0; t < endUs; t += TICK) {
let node = 2
for (const ev of events) {
if (t >= ev.startUs && t < ev.endUs) node = ev.nodeId
}
samples.push(node)
timeDeltas.push(TICK)
}
const fs = require('node:fs')
const path = './react-profile.cpuprofile'
fs.writeFileSync(path, JSON.stringify({ nodes, samples, startTime: 0, endTime: endUs, timeDeltas }))
console.log('Saved', path, '— Nodes:', nodes.length, 'Samples:', samples.length)
EOF
)"npx profano react-profile.cpuprofile --sort selfAlways use --sort self — the flat export makes self-time the meaningful metric.
Example output:
Duration: 47.23s
Samples: 786 active / 472317 total (99.8% idle)
Sort: self
Self %Self Self ms Total %Total Total ms Function Location
─────── ────── ─────── ─────── ────── ──────── ────────────────────── ──────────────
258 32.8% 25.8ms 258 32.8% 25.8ms Mount Components ⚛
87 11.1% 8.7ms 87 11.1% 8.7ms EditorialPage Components ⚛
73 9.3% 7.3ms 73 9.3% 7.3ms Update Blocked Transition
62 7.9% 6.2ms 62 7.9% 6.2ms Cascading Update Blocking
41 5.2% 4.1ms 41 5.2% 4.1ms SidebarTreeProvider Components ⚛
41 5.2% 4.1ms 41 5.2% 4.1ms ExpandableContainer Components ⚛
The Location column shows the React track: Components ⚛ for component renders, Transition/Blocking/Idle for scheduler events. Scheduler events like Mount, Cascading Update, and Update Blocked tell you why renders happened — cascading updates are a common perf smell.
React 19.2 calls performance.measure(componentName, { detail: { devtools: { track: 'Components ⚛', ... } } }) for component renders in development builds. It prefixes component names with a zero-width space (\u200b). The conversion creates a flat .cpuprofile where each unique component/event becomes a node under root, and samples are filled proportionally to each measure's duration. This is an approximate exclusive-time view — overlapping parent/child spans are collapsed, so --sort self is the useful view. It does not preserve the nested flamegraph structure that React DevTools shows.
For fine-grained control, use Node's built-in node:inspector module to start and stop the profiler around a specific code path and write the result to disk.
MIT