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.jswrites a.cpuprofilefile on exit; open it in Chrome DevTools (Performance tab) or VS Code.--cpu-prof-dirsets the location.- Or attach DevTools via
--inspectand 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 --profplusnode --prof-processis 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 DevToolsRemember
- 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.