A Node.js process pegging a CPU core at 100% is a rite of passage. The good news: Node has excellent profiling tools built in. The bad news: most people don't know they exist.
Here's my battle-tested workflow for finding the hot path.
1. Is It Really Node?
First, confirm the culprit:
# Find the PID
ps aux | grep node
# Check CPU usage
top -p <pid>
# Per-process CPU breakdown
pidstat -p <pid> 1 5
# Thread-level breakdown
top -H -p <pid>
If top -H shows one thread at 100% while others are idle, you likely have a synchronous hot loop in the main thread. If CPU is spread across threads, check the libuv thread pool (crypto, DNS, file I/O).
2. Built-in Profiler: --prof
Node's V8 profiler is always available — just start the process with a flag:
node --prof app.js
# Or attach to a running process
node --prof -p <pid>
Run the workload for 30-60 seconds, then process the output:
# Generate a human-readable profile
node --prof-process isolate-*.log > profile.txt
Reading the output: Look for ticks (samples) concentrated in a few functions. The [Summary] section tells you where time was spent:
JavaScript— your code or library codeC++— native bindings (fs, crypto, etc.)GC— garbage collectionRegex— regular expression execution (often the hidden culprit)
3. Flamegraphs with 0x
The 0x tool generates interactive flamegraphs that make hot paths visually obvious:
npx 0x app.js
# For an already-running process
npx 0x -p <pid>
This produces an HTML file. Open it in a browser and look for wide plateaus at the top of the flamegraph — those are functions consuming the most CPU. Wide bars = hot functions.
Pro tip: Filter the flamegraph by clicking on a function name to see only its callers and callees.
4. Async Hooks for I/O-Bound CPU
Sometimes high CPU comes from an I/O operation retrying in a tight loop:
const asyncHooks = require('async_hooks');
const hooks = asyncHooks.createHook({
init(asyncId, type, triggerAsyncId) {
if (type === 'TIMERWRAP' || type === 'PROMISE') {
// Track what initiated this async operation
const stack = new Error().stack;
// Log or aggregate these
}
}
});
hooks.enable();
This is especially useful for finding promise churn — thousands of promises being created and discarded per second.
5. Linux perf + Node
For production environments where you can't install npm packages:
# Record samples for 30 seconds
sudo perf record -p <pid> -g --sleep 30
# Generate flamegraph
sudo perf script | stackcollapse-perf | flamegraph.pl > flame.svg
This requires perf and Node compiled with --perf-basic-prof. Check:
node -e "console.log(process.config.variables.v8_enable_object_print)"
6. The Hidden Culprits Checklist
Based on every CPU incident I've debugged:
| Symptom | Likely Cause | Fix |
|---|---|---|
| GC dominates profile (10%+ ticks) | Too many short-lived allocations | Object pools, reduce allocations in hot paths |
| Regex in profile | ReDoS or inefficient regex | Use regexp-tree, add timeouts |
| JSON.parse in hot path | Large payloads parsed repeatedly | Cache parsed results, stream when possible |
| fs operations blocking | Synchronous fs in async context | Replace with fs.promises |
| crypto operations | PBKDF2/scrypt with high iterations | Offload to worker threads |
7. Production Diagnostics Without Restart
Can't restart the process? Use v8-inspector:
const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
session.post('Profiler.enable');
session.post('Profiler.start');
// Wait...
session.post('Profiler.stop', (err, { profile }) => {
// profile contains the CPU profile data
// Send it to a file or monitoring system
});
Or use the CLI:
node -e "
const s = new (require('inspector').Session)();
s.connect();
s.post('Profiler.enable');
s.post('Profiler.start');
setTimeout(() => {
s.post('Profiler.stop', (e, d) => {
require('fs').writeFileSync('profile.cpuprofile', JSON.stringify(d.profile));
});
}, 30000);
"
Then load profile.cpuprofile into Chrome DevTools for the full visual experience.
Quick Reference
# Quickest path to a hot function
node --prof app.js
node --prof-process isolate*.log | head -50
# Best visualization
npx 0x app.js
# Production-safe (no restart)
# Use inspector protocol via WebSocket or Node.js API
Start with --prof, switch to 0x flamegraphs when you need to understand call relationships.