NodeJSPerformance Hooks (perf_hooks)

Performance Hooks (perf_hooks)

The node:perf_hooks module exposes the Web Performance API (the same performance.now(), performance.mark(), performance.measure() you use in browsers) plus Node-specific APIs: high-resolution timing, GC observation, event loop delay monitoring, and HTTP/DNS/connection timings. It's the standard way to instrument code for performance analysis without reaching for external benchmarking libraries.

High-resolution timing with performance.now()

TS
import { performance } from 'node:perf_hooks'

// performance.now() returns milliseconds with sub-millisecond precision (microseconds):
const start = performance.now()

await someOperation()

const duration = performance.now() - start
console.log(`Operation took ${duration.toFixed(3)}ms`)

// Compared to Date.now() which has 1ms resolution and can skew with clock adjustments:
// Date.now()         → integer milliseconds, affected by NTP/clock changes
// performance.now()  → float milliseconds from process start, monotonically increasing
performance.now() is monotonically increasing and sub-millisecond precise — use it for timing measurements, not Date.now() which has 1ms resolution and is affected by clock adjustments
`performance.now()` returns a `DOMHighResTimeStamp` — a float representing milliseconds since the process started (or navigation start in browsers). It's **monotonically increasing**: it never jumps backward even if the system clock is adjusted by NTP, making it reliable for duration measurements. `Date.now()` returns wall-clock time in integer milliseconds, can jump backward, and has only 1ms resolution (or less in some security-hardened environments). For measuring code durations, always use `performance.now()`. For timestamps you need to display or store as absolute time, `Date.now()` or `new Date()` is appropriate.
User timing: mark and measure

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

// Mark named points in time:
performance.mark('db-query-start')
await db.query('SELECT ...')
performance.mark('db-query-end')

// Measure the interval between marks:
performance.measure('db-query', 'db-query-start', 'db-query-end')

// Read the measure:
const [measure] = performance.getEntriesByName('db-query', 'measure')
console.log(`DB query: ${measure.duration.toFixed(2)}ms`)

// Or observe all marks/measures via PerformanceObserver (non-blocking):
const obs = new PerformanceObserver((items) => {
  for (const entry of items.getEntries()) {
    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`)
  }
  performance.clearMarks()
  performance.clearMeasures()
})
obs.observe({ entryTypes: ['measure', 'mark'] })
Observing GC events

TS
import { PerformanceObserver, constants } from 'node:perf_hooks'

const gcObs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const detail = entry.detail as { kind: number; flags: number }
    const type = detail.kind === constants.NODE_PERFORMANCE_GC_MAJOR
      ? 'major' : detail.kind === constants.NODE_PERFORMANCE_GC_MINOR
      ? 'minor' : 'incremental'

    console.log(`GC [${type}] ${entry.duration.toFixed(2)}ms`)
  }
})
gcObs.observe({ entryTypes: ['gc'] })
GC [minor] 0.84ms
GC [minor] 1.02ms
GC [major] 12.37ms
Event loop delay monitoring

TS
import { monitorEventLoopDelay } from 'node:perf_hooks'

// Histogram of event loop delay sampled every 10ms:
const histogram = monitorEventLoopDelay({ resolution: 10 })
histogram.enable()

// Report percentiles periodically:
setInterval(() => {
  const stats = {
    min:  (histogram.min  / 1e6).toFixed(2) + 'ms',
    mean: (histogram.mean / 1e6).toFixed(2) + 'ms',
    p50:  (histogram.percentile(50)  / 1e6).toFixed(2) + 'ms',
    p95:  (histogram.percentile(95)  / 1e6).toFixed(2) + 'ms',
    p99:  (histogram.percentile(99)  / 1e6).toFixed(2) + 'ms',
    max:  (histogram.max  / 1e6).toFixed(2) + 'ms',
  }
  console.log('Event loop delay:', stats)
  histogram.reset()
}, 10_000)

// Values are in nanoseconds — divide by 1e6 for milliseconds.
// p99 > 10ms consistently indicates event loop blocking that needs investigation.
monitorEventLoopDelay measures how long the event loop takes between ticks — p99 above 10ms suggests blocking code or GC pauses that need investigation
The event loop delay histogram measures how much time passes between when a timer fires and when the event loop actually processes it. In a perfectly idle process, this should be near zero. Under load, event loop delay rises as the poll queue fills up and the loop spends time in callbacks. Sudden spikes indicate blocking I/O or CPU work on the main thread; sustained high delay indicates the process is consistently CPU-saturated. This metric (along with `heapUsed` and request latency) is one of the three key Node.js health indicators to export as a Prometheus gauge or StatsD metric in production.
HTTP timing details (Node's built-in)

TS
import { performance, PerformanceObserver } from 'node:perf_hooks'
import http from 'node:http'

// Observe HTTP request timing details (DNS lookup, TCP connect, TLS, TTFB, etc.):
const obs = new PerformanceObserver((items) => {
  for (const entry of items.getEntries()) {
    const detail = entry.detail as any
    console.log({
      url:         entry.name,
      total:       entry.duration.toFixed(2) + 'ms',
      dns:         detail.dnsLookupAt ? (detail.dnsLookupAt - entry.startTime).toFixed(2) + 'ms' : 'n/a',
      connect:     detail.connectAt   ? (detail.connectAt   - entry.startTime).toFixed(2) + 'ms' : 'n/a',
      firstByte:   detail.firstByteAt ? (detail.firstByteAt - entry.startTime).toFixed(2) + 'ms' : 'n/a',
    })
  }
})
obs.observe({ entryTypes: ['http'] })

// Make a request — timing is captured automatically:
http.get('http://example.com', (res) => res.resume())
Benchmarking with timerify

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

function sortArray(arr: number[]): number[] {
  return [...arr].sort((a, b) => a - b)
}

// Wrap a function to automatically record its execution time on each call:
const timedSort = performance.timerify(sortArray)

const obs = new PerformanceObserver((items) => {
  for (const entry of items.getEntries()) {
    console.log(`${entry.name}: ${entry.duration.toFixed(3)}ms`)
  }
  obs.disconnect()
})
obs.observe({ entryTypes: ['function'], buffered: true })

// Each call to timedSort records an entry:
const data = Array.from({ length: 10_000 }, () => Math.random())
timedSort(data)
timedSort(data)
sortArray: 2.145ms
sortArray: 1.832ms
perf_hooks vs external benchmarking

Tool

Best for

performance.now() + manual deltas

Quick inline timing of a code section

performance.mark/measure

Multi-point profiling with named spans; DevTools compatible

monitorEventLoopDelay

Continuous production monitoring of event loop health

PerformanceObserver({ entryTypes: ['gc'] })

GC pause monitoring in production

performance.timerify

Benchmarking a specific function across many calls

node --prof + node --prof-process

CPU profiling with V8 tick sampling; finds hot functions

clinic.js (npm install -g clinic)

Comprehensive profiling (flame graphs, event loop, memory) for production-like workloads

Micro-benchmarks measuring nanoseconds are often misleading — V8 JIT compilation warms up over many iterations; always run enough iterations and discard the first few
V8 doesn't compile JavaScript to optimized machine code immediately — it starts with an interpreter, then compiles hot functions with Turbofan after enough executions. If you time a function on the first call, you're measuring the interpreter, not the optimized JIT output. For meaningful benchmarks, call the function at least 10,000 times to trigger JIT optimization, discard the first few hundred results as warm-up, and report mean and percentiles rather than a single timing. Libraries like `benchmark.js` handle this correctly; manual `for` loops with `performance.now()` often don't.
Next
Execute untrusted JavaScript in a sandbox: [The vm Module](/nodejs/vm-module).