Skip to content
Node.js nd debugging 4 min read

Measuring Performance with perf_hooks

When you need to understand why a function is slow, guessing is not enough — you need precise, high-resolution measurements. Node’s built-in perf_hooks module exposes the same Performance Timing API found in browsers, giving you sub-millisecond timers, named marks and measures, and an observer pattern for collecting results asynchronously. It is the right tool whenever console.time is too coarse or you want structured timing data you can log, aggregate, or ship to a monitoring backend.

Getting started

Everything lives under the node:perf_hooks module. The two pieces you reach for most are the global-like performance object and the PerformanceObserver class.

import { performance, PerformanceObserver } from 'node:perf_hooks';

In CommonJS the equivalent is const { performance, PerformanceObserver } = require('node:perf_hooks'). Note that performance is also exposed on the global scope in modern Node (20/22), so you can often use it without importing — but importing makes intent explicit and keeps tooling happy.

High-resolution timing with performance.now()

performance.now() returns a DOMHighResTimeStamp: a floating-point number of milliseconds, measured from an arbitrary but monotonic origin. Because it is monotonic, it never jumps backward when the system clock is adjusted, which makes it far more reliable than Date.now() for measuring elapsed time.

import { performance } from 'node:perf_hooks';

function fibonacci(n) {
  return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}

const start = performance.now();
const result = fibonacci(35);
const elapsed = performance.now() - start;

console.log(`fib(35) = ${result} in ${elapsed.toFixed(3)} ms`);

Output:

fib(35) = 9227465 in 84.612 ms

Use performance.now() for durations, not wall-clock timestamps. If you need an actual date, combine performance.timeOrigin (the Unix time of the origin) with performance.now().

Marks and measures

For anything beyond a single timer, named marks and measures scale better. A mark records a labeled instant; a measure computes the duration between two marks (or between a mark and now). These entries are stored in Node’s performance timeline and surfaced through a PerformanceObserver.

import { performance, PerformanceObserver } from 'node:perf_hooks';

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`${entry.name}: ${entry.duration.toFixed(2)} ms`);
  }
});
observer.observe({ entryTypes: ['measure'] });

performance.mark('load-start');
await loadConfig();
performance.mark('load-end');

performance.mark('query-start');
await runQuery();
performance.mark('query-end');

performance.measure('config load', 'load-start', 'load-end');
performance.measure('db query', 'query-start', 'query-end');

async function loadConfig() {
  return new Promise((r) => setTimeout(r, 40));
}
async function runQuery() {
  return new Promise((r) => setTimeout(r, 120));
}

Output:

config load: 41.18 ms
db query: 121.07 ms

The observer fires asynchronously after the measures are created, so it does not add overhead to the hot path you are timing. Call performance.clearMarks() and performance.clearMeasures() periodically in long-running processes to avoid accumulating timeline entries.

PerformanceObserver entry types

You can observe several categories of entries, not just your own measures.

Entry typeWhat it reports
markTimestamps created with performance.mark()
measureDurations created with performance.measure()
functionCalls wrapped with performance.timerify()
gcGarbage collection pauses
httpHTTP client/server request timings
dnsDNS lookup timings

Pass a single type with { type: 'gc', buffered: true } or several at once with { entryTypes: ['measure', 'gc'] }.

Timing functions automatically with timerify()

performance.timerify() wraps a function so every call emits a function entry with its duration — handy when you want to profile a specific hot function without scattering marks through it.

import { performance, PerformanceObserver } from 'node:perf_hooks';

function hash(input) {
  let h = 0;
  for (const ch of input) h = (h * 31 + ch.charCodeAt(0)) | 0;
  return h;
}

const timedHash = performance.timerify(hash);

new PerformanceObserver((list) => {
  const e = list.getEntries()[0];
  console.log(`${e.name} took ${e.duration.toFixed(3)} ms`);
}).observe({ entryTypes: ['function'] });

timedHash('the quick brown fox jumps over the lazy dog');

Output:

hash took 0.041 ms

Monitoring event loop delay

A slow Node process is often the result of event loop lag rather than any one function. monitorEventLoopDelay() samples how late timers fire, accumulating the results into a histogram you can inspect at any time. Because it runs in C++ outside the JavaScript thread, it measures delay without contributing to it.

import { monitorEventLoopDelay } from 'node:perf_hooks';

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

// Simulate a blocking workload.
const until = Date.now() + 500;
while (Date.now() < until) {}

setTimeout(() => {
  h.disable();
  console.log(`min:   ${(h.min / 1e6).toFixed(2)} ms`);
  console.log(`mean:  ${(h.mean / 1e6).toFixed(2)} ms`);
  console.log(`max:   ${(h.max / 1e6).toFixed(2)} ms`);
  console.log(`p99:   ${(h.percentile(99) / 1e6).toFixed(2)} ms`);
}, 1000);

Output:

min:   0.01 ms
mean:  3.47 ms
max:   501.22 ms
p99:   501.22 ms

Histogram values are reported in nanoseconds, so divide by 1e6 to get milliseconds. A rising p99 is an early warning that synchronous work is starving the loop.

Best Practices

  • Prefer performance.now() over Date.now() for durations — it is monotonic and higher resolution.
  • Use marks and measures for multi-step flows so each phase is separately attributable, rather than one opaque timer.
  • Keep a PerformanceObserver lightweight; it runs on every flush, so avoid heavy work or synchronous I/O inside its callback.
  • Clear marks and measures (clearMarks, clearMeasures) in long-lived services to prevent the timeline from growing unbounded.
  • Track event loop delay with monitorEventLoopDelay() in production and alert on percentile(99) to catch blocking code early.
  • Convert histogram values from nanoseconds to milliseconds before logging to avoid confusing readings.
  • Measure in a realistic environment — JIT warm-up means the first few calls are slower than steady state.
Last updated June 14, 2026
Was this helpful?