Profiling CPU and event-loop performance

Node.js · Advanced · 16 min

Performance work without measurement optimises the wrong thing; profiles show where time actually goes.

Start every performance investigation with a reproducible load (autocannon for HTTP, a benchmark script for libraries) and a number to improve: latency percentiles, throughput, or event-loop delay.

CPU profiling shows which functions consume time:

  • node --cpu-prof app.js writes a .cpuprofile file on exit; open it in Chrome DevTools (Performance tab) or VS Code. --cpu-prof-dir sets the location.
  • Or attach DevTools via --inspect and record in the Performance/Profiler panel while load runs.
  • Read it as a flame graph: wide bars are where time goes. Look for your own functions near the top of wide stacks — JSON serialisation, regexes, sync crypto, deep cloning, logging are frequent culprits.
  • node --prof plus node --prof-process is the lower-level V8 tick profiler, useful on servers without a GUI.

Event-loop delay is the best single health metric for a Node service: how late callbacks run. perf_hooks.monitorEventLoopDelay() gives a histogram (p50, p99, max in nanoseconds). A rising p99 means something synchronous is hogging the thread.

Measuring code: performance.now() for durations, performance.mark/measure for named spans, performance.timerify(fn) to time a function, and PerformanceObserver to collect entries, including GC events (entryTypes: ["gc"]).

Typical fixes, in order of payoff: do less work (cache, paginate), avoid sync APIs on hot paths, move CPU work to workers, then micro-optimise. Benchmark micro-optimisations properly — V8's JIT makes naive timing loops misleading — and re-profile after each change.

Example

import { monitorEventLoopDelay, performance, PerformanceObserver } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const obs = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) console.log(`${e.name}: ${e.duration.toFixed(1)} ms`);
});
obs.observe({ entryTypes: ["measure"] });

function blockFor(ms) { const end = Date.now() + ms; while (Date.now() < end); }

performance.mark("start");
for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(() => { blockFor(40); r(); }, 10));
performance.mark("end");
performance.measure("work", "start", "end");        // work: ~250 ms

setTimeout(() => {
  h.disable();
  console.log("loop delay p99 ms:", (h.percentile(99) / 1e6).toFixed(1));  // ~40: blocked!
}, 50);
// CPU profile: node --cpu-prof this-file.js, then open the .cpuprofile in DevTools

Remember

  • Measure under realistic load before optimising
  • --cpu-prof + flame graphs show where CPU time goes
  • Event-loop delay (p99) is the key health metric
  • Fix algorithmic waste and sync work before micro-optimising

Watch out for

  • Optimising based on intuition or a micro-benchmark without profiling the real workload.

Check yourself

  1. Your API's p99 latency spikes while CPU is moderate and the DB is fast. Which metric best confirms the event loop is being blocked?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/getting-started/profiling