Find the Hot Path in Node with a CPU Flame Graph
Capture a sampling profile under load, read the flame graph width-first to find the widest frames, and verify the fix with a second capture.
Task
Profile a CPU-bound Node process under representative load, identify the single widest caller chain, and cut its cost measurably.
When to use this
The API doubles its CPU under normal traffic. Monitoring shows hot CPU but not where, and three engineers have three different theories.
Prerequisites
- Node.js 16 or newer with --cpu-prof or 0x installed
- A load generator that reproduces production traffic shape
- Permission to restart the process in profiling mode
Steps
1. Start the process with CPU profiling enabled.
node --cpu-prof --cpu-prof-dir=/tmp/prof --cpu-prof-interval=1000 dist/server.js & sleep 2; ab -n 2000 -c 50 https://staging.example.com/api/search/ > /dev/null 2>&1; ls /tmp/prof/Expected output: A .cpuprofile file appears after the load window, containing sampled stacks from the CPU-bound run.
2. Summarize the profile to a text hot list first.
CPUPROF=$(ls /tmp/prof/*.cpuprofile | head -1); node -e "const p=require(process.argv[1]); const m={}; p.nodes.forEach(n => { const k = n.callFrame.functionName || '(anon)'; if (n.hitCount) m[k] = (m[k] || 0) + n.hitCount; }); Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10).forEach(([k, v]) => console.log(v, k))" "$CPUPROF" | head -12Expected output: A hit-count ranking prints; one function such as escapeHtml or deepClone usually dominates with several times the runner-up.
3. Read the flame graph width-first to see the full calling chain.
npx 0x --output-dir=/tmp/flame dist/server.js & sleep 2; ab -n 2000 -c 50 https://staging.example.com/api/search/ >/dev/null 2>&1; ls /tmp/flame/*.html 2>/dev/null | head -2 || echo "open .cpuprofile in Chrome DevTools > Performance > Load"Expected output: The interactive flame graph opens; the widest tower from main to the hot function names every layer that contributes to the cost.
4. Fix the widest frame, not the one with the familiar name.
// hot: called 4x per row, allocates each time
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"'": '"', "'": ''' }[c]!));
}
// fix 1: hoist the map, reuse the compiled regex
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const ESC_RE = /[&<>"']/g;
const escapeHtml = (s: string) => s.replace(ESC_RE, (c) => ESC[c]);
// fix 2 (bigger win): only escape once per field, at render entry
rows.forEach((r) => { r._html = escapeHtml(r.title); });
Expected output: A second profile shows the escapeHtml tower shrink from 31 percent of samples to under 3 percent, with total CPU time falling accordingly.
5. Capture the after picture and compare numerically.
node --cpu-prof --cpu-prof-dir=/tmp/prof2 dist/server.js & sleep 2; ab -n 2000 -c 50 https://staging.example.com/api/search/ > /dev/null 2>&1; node -e "const fs=require('fs');const f=fs.readdirSync('/tmp/prof2').pop();const p=require('/tmp/prof2/'+f);let hits=0,total=0;p.nodes.forEach(n=>{total+=n.hitCount||0;if((n.callFrame.functionName||'').includes('escapeHtml'))hits+=n.hitCount||0;});console.log('escapeHtml share:', (100*hits/total).toFixed(1)+'%')"Expected output: The script prints the escaped-function share before and after, turning the optimization into a number the PR description can cite.
Troubleshooting
Problem
Profile is empty or nearly so
Solution
The load window was too short or the process was idle; extend the capture across a real traffic burst, and verify the .cpuprofile has nonzero node hitCounts.
Problem
Everything is wide, nothing dominant
Solution
The cost is spread across framework dispatch; profile at higher resolution, look at module-level aggregation, or optimize at the architecture level (caching, fewer rows) instead of micro-fixes.
Problem
Profiling changes behavior too much
Solution
Sampling at 1000 microsecond intervals is already light; if overhead still matters, capture on one replica out of ten and extrapolate, keeping production traffic untouched.
Limitations
- Sampling shows where time went, not why; the widest frame still needs code reading to find the fixable inefficiency.
- A single capture is one traffic shape; re-profile after major feature launches because hot paths migrate.
Read the graph by width, not by height
A flame graph stacks callers vertically, but cost is horizontal: the widest frame consumed the most samples. Reading width-first answers the only question that matters, where did the CPU actually go, without any theory from anyone.
main ................................. 100%
├─ server.dispatch .................. 88%
│ ├─ renderSearch ................. 61%
│ │ ├─ escapeHtml ............... 31% <-- widest leaf: the fix
│ │ ├─ deepClone ................ 12%
│ │ └─ JSON.stringify ............ 8%
│ └─ auth.middleware .............. 9%
└─ gc ................................ 4%
after hoisting the escape map + escaping once per field:
├─ renderSearch ..................... 38%
│ ├─ escapeHtml .................... 2.4%
The widest frame names the PR
Micro-optimizations feel productive and change nothing measurable. The width-first read concentrates effort on the frame that owns a third of all samples, so the fix moves a number the whole team can see. Everything narrower than five percent is noise until the big towers fall.
Before-and-after captures close the loop
An optimization without a second profile is a story. Re-running the same load and same analysis produces a share percentage for the fixed function, which becomes the evidence in the pull request and the baseline for the next investigation.