Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 071 / 100

Measure with performance.now(): best practices explained

Stop guessing. Time it.

Expona Admin6 min read

Measure with performance.now(): best practices explained

Guessing at performance is how slow applications ship to production. When you need to know whether a refactor actually helped, or whether a third-party library is the bottleneck, you reach for timers—and JavaScript gives us tools far more precise than the old Date.now() approach. We'll walk through how to use performance.now() effectively, layer in the mark and measure APIs, and build confidence in your timing data so you can make real performance decisions.

High-resolution timing

performance.now() returns a DOMHighResTimeStamp with microsecond precision, whereas Date.now() gives you only milliseconds. In practice, that means you can measure operations that complete in less than a millisecond—critical when optimizing hot loops or benchmarking algorithm choices.

Here's the essential pattern:

typescript
const start = performance.now();

// ... operation to measure ...

const end = performance.now();
const duration = end - start;

console.log(`Took ${duration.toFixed(2)}ms`);

The difference matters. If you're comparing two sorting algorithms on a small dataset, a 0.3ms advantage is real—and Date.now() would round both to 0ms, hiding the win entirely. performance.now() sees it.

One critical detail: performance.now() is monotonic—it never goes backwards. Date.now() can jump around if the system clock adjusts. For benchmarking, this makes performance.now() reliable across different environments.

You can also use this in Node.js via the perf_hooks module:

typescript
import { performance } from 'perf_hooks';

const start = performance.now();
// ... work ...
const end = performance.now();
console.log(`Duration: ${(end - start).toFixed(2)}ms`);

This approach works in CLI tools, backend services, and scripts where you're tuning database queries or batch processing pipelines.

performance.mark and measure for spans

Wrapping individual operations in start/end calls gets messy fast, especially when you're tracking multiple overlapping spans. The performance.mark() and performance.measure() APIs let you name regions of time and query them later, building a structured timeline.

Here's a realistic example: you're profiling a React component's lifecycle and want to track render, effect setup, and data fetch separately:

typescript
export function DataPanel({ id }: { id: string }) {
  useEffect(() => {
    performance.mark(`fetch-start-${id}`);

    fetch(`/api/data/${id}`)
      .then((res) => res.json())
      .then((data) => {
        performance.mark(`fetch-end-${id}`);
        performance.measure(
          `fetch-${id}`,
          `fetch-start-${id}`,
          `fetch-end-${id}`
        );
      });
  }, [id]);

  performance.mark(`render-start-${id}`);

  const content = useMemo(() => {
    // expensive computation
    return processData(data);
  }, [data]);

  performance.mark(`render-end-${id}`);
  performance.measure(`render-${id}`, `render-start-${id}`, `render-end-${id}`);

  return <div>{content}</div>;
}

Now you can extract all measures programmatically:

typescript
const measures = performance.getEntriesByType('measure');
measures.forEach((entry) => {
  console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});

This builds a timeline you can log, send to monitoring systems, or visualize in DevTools. The browser DevTools Performance tab will even show your custom marks and measures alongside native events, making the full flow visible.

Marks are free; measures carry a small cost because they're stored in the performance timeline. For high-frequency code, be selective about which measurements you keep.

Repeat measurements to reduce noise

A single timing run is almost useless. Garbage collection, cache state, CPU scheduling, and network jitter all introduce variance. Run the operation 50, 100, or 1,000 times and compute statistics.

Here's a helper that captures realistic benchmark data:

typescript
async function benchmark(
  name: string,
  fn: () => void | Promise,
  iterations: number = 100
): Promise {
  const times: number[] = [];

  for (let i = 0; i < iterations; i++) {
    const start = performance.now();
    await fn();
    const end = performance.now();
    times.push(end - start);
  }

  times.sort((a, b) => a - b);

  const min = times[0];
  const max = times[times.length - 1];
  const mean = times.reduce((a, b) => a + b, 0) / times.length;
  const median = times[Math.floor(times.length / 2)];
  const p95 = times[Math.floor(times.length * 0.95)];
  const p99 = times[Math.floor(times.length * 0.99)];

  console.log(`${name}:`);
  console.log(`  min:    ${min.toFixed(2)}ms`);
  console.log(`  median: ${median.toFixed(2)}ms`);
  console.log(`  mean:   ${mean.toFixed(2)}ms`);
  console.log(`  p95:    ${p95.toFixed(2)}ms`);
  console.log(`  p99:    ${p99.toFixed(2)}ms`);
  console.log(`  max:    ${max.toFixed(2)}ms`);
}

// Compare two sorting approaches
await benchmark('Array.sort()', () => {
  const arr = Array.from({ length: 1000 }, () => Math.random());
  arr.sort((a, b) => a - b);
});

await benchmark('Custom quicksort', () => {
  const arr = Array.from({ length: 1000 }, () => Math.random());
  quickSort(arr);
});

The median and percentiles tell you more than the mean. A single garbage collection pause that spiked one run to 50ms will skew the average, but the p95 and p99 capture real-world user experience—what most users see most of the time, and what the slowest 5% experience.

In production, collect histograms of these numbers over time. A p99 that slowly creeps up from 20ms to 40ms signals a real regression, not random noise.

Profile in the browser for the full picture

Synthetic benchmarks are a starting point, but they're artificial. In the browser, open DevTools Performance tab, record a session, and watch your code run with real DOM, real events, and real GC pressure. The timeline shows you marks and measures alongside paint, layout, and script execution.

Instrument your code with marks before recording:

typescript
export function ExpensiveComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    performance.mark('data-fetch-start');

    fetchData().then((result) => {
      performance.mark('data-fetch-end');
      performance.measure('data-fetch', 'data-fetch-start', 'data-fetch-end');
      setData(result);
    });
  }, []);

  useEffect(() => {
    if (!data) return;

    performance.mark('render-compute-start');
    const processed = processLargeDataset(data);
    performance.mark('render-compute-end');
    performance.measure(
      'render-compute',
      'render-compute-start',
      'render-compute-end'
    );
  }, [data]);

  return <div>{/* ... */}</div>;
}

Hit record in the Performance tab, interact with your component, then stop. Your marks and measures appear on the timeline in their own rows, synchronized with frames, layouts, and garbage collection events. You'll immediately see whether your compute block is blocking the main thread before paint, or whether the network request is the actual bottleneck.

This context—seeing marks in relation to the rest of the browser's work—is where real insights emerge. A function might show 5ms in isolation but 30ms in the profile because of layout thrashing or competing work on the main thread.

Common mistakes

  • Trusting a single run. One measurement is noise. Always repeat and analyze distributions.
  • Not clearing old marks. The performance timeline is global and persistent. Call performance.clearMarks() and performance.clearMeasures() between test runs or you'll measure stale data.
  • Forgetting about GC. Force garbage collection before benchmarks (in Node.js with --expose-gc and global.gc()) or your cold runs will look different from warm runs.
  • Measuring in development mode. Build your code for production (minification, tree-shaking, optimizations) before serious benchmarking. Development bundles can be 10x slower.
  • Ignoring the browser's measurement overhead. performance.mark() has a cost, small but real. For tight loops, batch your measurements or measure every Nth iteration.
  • Missing observer context. If you're doing performance work across micro-frontends or sandboxed contexts, marks may not be visible outside their origin. Check your isolation model.

Key takeaways

  • Use performance.now() for high-resolution timing; it has microsecond precision and monotonic behavior, unlike Date.now().
  • Layer in performance.mark() and performance.measure() to build a queryable timeline of named operations, especially in complex workflows.
  • Always run benchmarks multiple times and compute statistics (median, p95, p99) to cut through noise and capture what users actually experience.
  • Profile in the browser DevTools to see your marks in context alongside paint, layout, and garbage collection—synthetic benchmarks alone miss critical interactions.
  • Clear marks between test runs, build for production before benchmarking, and force GC if you're comparing cold and warm performance.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles