10LOC
Node.jsintermediate

performance.now() / hrtime.bigint() for accurate micro-benchmarks

Published January 29, 2027

import { performance } from "node:perf_hooks";

const benchmark = (fn: () => void, iterations = 100_000) => {
  const start = process.hrtime.bigint();
  for (let i = 0; i < iterations; i++) fn();
  const end = process.hrtime.bigint();
  const totalNs = end - start;
  return Number(totalNs) / iterations; // average nanoseconds per call
};

const avgNs = benchmark(() => JSON.stringify({ a: 1, b: 2 }));
console.log(`${avgNs.toFixed(1)} ns/call (process uptime: ${performance.now().toFixed(0)}ms)`);

What

process.hrtime.bigint() returns the current high-resolution time as a bigint count of nanoseconds since an arbitrary starting point — subtract two readings and you get an exact elapsed-nanosecond integer, with no floating-point rounding involved.

Why it matters

Date.now() is the wrong tool for timing code: it's millisecond resolution and it's wall-clock time, which can jump backward or forward if the system clock adjusts (NTP sync, manual change) mid-measurement. performance.now() fixes the wall-clock problem — it's monotonic, immune to clock adjustments — but it's still a floating-point number of milliseconds, which is plenty for timing a page load but coarse for timing a function call that might take a few thousand nanoseconds.

process.hrtime.bigint() is the one built for that: nanosecond resolution, returned as a bigint so subtracting two large timestamps never loses precision the way floating-point subtraction eventually does. For a genuine micro-benchmark — "how long does this one function call take" — it's the more honest instrument.

How it works

  • benchmark(fn, iterations) runs fn in a tight loop rather than timing a single call — a single call's duration is usually smaller than the timer's practical noise floor, so averaging over many calls is what makes the number meaningful.
  • process.hrtime.bigint() bookends the loop; end - start is a bigint nanosecond count, safe from precision loss even for durations that would lose accuracy as a number.
  • Number(totalNs) / iterations converts back to a regular number only at the very end, for the average-per-call figure — safe here because the total elapsed time for 100,000 fast calls is comfortably below Number.MAX_SAFE_INTEGER nanoseconds (about 104 days).
  • performance.now() shows up separately, reporting elapsed process uptime in milliseconds — the right tool when you don't need nanosecond precision, just a readable "how long has this been running" figure.

Gotchas

  • Both clocks are relative to an arbitrary epoch (roughly process start), not wall-clock time — don't use either to timestamp events for storage or display; that's what Date.now() or new Date() are for.
  • A single loop iteration is not a reliable benchmark. JIT warm-up, garbage collection pauses, and CPU frequency scaling all add noise — run multiple trials and look at the distribution, not one number, before drawing conclusions.
  • Number(bigint) silently loses precision above Number.MAX_SAFE_INTEGER (2^53). It's safe for the durations in this snippet; it stops being safe if you're timing something that runs for a very long time in nanoseconds.

Related

  • console.time() / console.timeEnd() — a quick-and-dirty wrapper over the same idea, fine for casual debugging, less precise and harder to use programmatically than raw hrtime.bigint().
  • perf_hooks.PerformanceObserver — for capturing timing data Node itself emits (GC pauses, HTTP request timing) rather than timing your own code manually.