Investigating High CPU Usage in Node.js Applications

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 code
  • C++ — native bindings (fs, crypto, etc.)
  • GC — garbage collection
  • Regex — 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.