Precise Runtime Measurement with performance.now() and PerformanceObserver
Date.now() delivers milliseconds with a precision that is not sufficient for serious performance measurement, and falls into the trap of system clock synchronization. The JavaScript Performance API provides sub-millisecond timestamps, structured marks and measures, a non-blocking observer mechanism, and direct access to Web Vitals without an external library.
Table of Contents
- 1. Why Date.now() is unsuitable for performance measurement
- 2. performance.now(): sub-millisecond precision and monotonicity
- 3. User Timing: performance.mark() and performance.measure()
- 4. PerformanceObserver: asynchronous and non-blocking measurement
- 5. Resource Timing: analyzing network load times
- 6. Long Tasks API: detecting jank and blocking time
- 7. Measuring Web Vitals directly with the Performance API
- 8. Performance API in Node.js: perf_hooks and Worker Threads
- 9. Performance API: methods compared
- 10. Summary
- 11. FAQ
1. Why Date.now() is unsuitable for performance measurement
The most common approach for runtime measurement in JavaScript is the difference between two Date.now() calls. The problem: Date.now() returns the number of milliseconds since the Unix epoch and is tied to the system clock. The system clock can change, through NTP synchronization, manual adjustment, or daylight saving time transitions. This means a time interval measured with Date.now() can be negative if the system clock is set back during the measurement. For short time spans, Date.now() also only delivers integer milliseconds without sub-millisecond resolution.
The JavaScript Performance API solves both problems. performance.now() is based on a monotonic clock that is guaranteed to never run backward, independent of system clock changes. The returned number is a floating-point value with sub-millisecond precision, rounded to 0.1 ms in browsers for security reasons, and with full CPU resolution in non-security contexts (for example Node.js). The starting point is the page's navigation start, not the Unix epoch, which makes the values smaller and easier to read.
2. performance.now(): sub-millisecond precision and monotonicity
performance.now() returns the number of milliseconds since the navigation start of the current page, as a floating-point number with up to five decimal places. The value is monotonic: it never decreases, even with system clock changes. That makes it the reliable foundation for all Performance API measurements. The difference between two performance.now() calls is always non-negative and reflects the actual elapsed computation and wait time.
A common misunderstanding: performance.now() does not measure CPU time, but wall-clock time, including wait times for I/O, network, and timers. Anyone who wants to measure only CPU time needs Worker Threads or server-side measurements. In the browser, wall-clock time is usually what you want to measure: the time a user actually waits for a response. The Performance API delivers exactly that number, precisely and without system clock dependency.
// performance.now(): monotonic, sub-millisecond, navigation-relative
const start = performance.now();
// Simulate some work
for (let i = 0; i < 1_000_000; i++) { Math.sqrt(i); }
const end = performance.now();
const delta = end - start; // always >= 0, sub-millisecond precision
console.log(`Loop took ${delta.toFixed(3)} ms`);
// Compare with Date.now(): integer ms, wall-clock dependent
const t1 = Date.now();
for (let i = 0; i < 1_000_000; i++) { Math.sqrt(i); }
const t2 = Date.now();
console.log(`Date.now delta: ${t2 - t1} ms`); // integer, could be 0 for fast code
// Safe for cross-frame timing: origin-relative, not epoch-relative
// performance.timeOrigin gives the absolute epoch offset
console.log(`Page loaded at: ${new Date(performance.timeOrigin).toISOString()}`);
console.log(`Current offset: ${performance.now().toFixed(3)} ms after load`);
3. User Timing: performance.mark() and performance.measure()
The User Timing API is the most expressive layer of the Performance API for application code. Calling performance.mark("name") stores a named timestamp in the performance buffer. Calling performance.measure("name", "start-mark", "end-mark") stores the difference between two marks as a PerformanceMeasure entry, which is automatically visible in browser DevTools under the "Timings" timeline section. This means your own measurements are visualized directly alongside browser-internal timings such as navigation and resource loading.
A key advantage over a manual performance.now() difference approach: marks and measures are stored in a separate buffer and can be retrieved at any time with performance.getEntriesByType("mark") and performance.getEntriesByType("measure"), even after the measured code section has finished, for example in an error handler or a reporting callback. With performance.clearMarks() and performance.clearMeasures(), stale entries can be removed from the buffer to avoid memory leaks in long-running applications.
// User Timing API: named marks and measures visible in DevTools Timeline
async function loadUserData(userId) {
performance.mark("loadUserData:start");
// Phase 1: fetch user
performance.mark("fetchUser:start");
const user = await fetch(`/api/users/${userId}`).then(r => r.json());
performance.mark("fetchUser:end");
performance.measure("Fetch User", "fetchUser:start", "fetchUser:end");
// Phase 2: fetch user's orders
performance.mark("fetchOrders:start");
const orders = await fetch(`/api/orders?userId=${userId}`).then(r => r.json());
performance.mark("fetchOrders:end");
performance.measure("Fetch Orders", "fetchOrders:start", "fetchOrders:end");
performance.mark("loadUserData:end");
performance.measure("Total loadUserData", "loadUserData:start", "loadUserData:end");
// Retrieve measures for reporting
const measures = performance.getEntriesByType("measure");
measures.forEach(m => console.log(`${m.name}: ${m.duration.toFixed(2)} ms`));
// Clean up to avoid buffer bloat in long-running apps
performance.clearMarks();
performance.clearMeasures();
return { user, orders };
}
4. PerformanceObserver: asynchronous and non-blocking measurement
The PerformanceObserver is the modern, reactive interface to the Performance API. Instead of polling a buffer (getEntriesByType()), you register a callback that is invoked asynchronously as soon as new performance entries of a given type become available. This is especially important for metrics that occur continuously, such as Resource Timing, Long Tasks, and Layout Shifts, where a polling-based approach would either arrive too late or waste CPU time unnecessarily.
The PerformanceObserver does not block the main thread: the callback runs as a microtask after the current event loop tick. That makes it ideal for production monitoring that must not affect the user experience. A single observer can watch multiple entry types at once (observe({ type: "mark" }), observe({ type: "measure" })). Calling observer.disconnect() stops the observer when no further entries are expected.
5. Resource Timing: analyzing network load times
The Resource Timing API is part of the Performance API and automatically captures detailed timing information for every resource a page loads: scripts, stylesheets, images, fonts, and XHR/fetch requests. Each entry provides timestamps: startTime, fetchStart, domainLookupStart, connectStart, requestStart, responseStart, responseEnd. From these timestamps you can calculate DNS lookup time, TCP connection time, TTFB (Time to First Byte), and download time.
The Resource Timing API is particularly valuable for identifying network bottlenecks without external monitoring tools. If a script takes 800 ms to load, the Resource Timing API shows whether the problem lies in DNS resolution, the TCP connection, TTFB, or the actual download. That enables targeted optimizations: CDN setup, preconnect hints, HTTP/2 multiplexing, or caching strategies, driven by data based on real user data, not lab values.
// PerformanceObserver for Resource Timing: non-blocking, reactive
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only analyze resources slower than 200 ms
if (entry.duration < 200) continue;
const dns = entry.domainLookupEnd - entry.domainLookupStart;
const tcp = entry.connectEnd - entry.connectStart;
const ttfb = entry.responseStart - entry.requestStart;
const download = entry.responseEnd - entry.responseStart;
console.group(`Slow resource: ${entry.name}`);
console.log(`Total: ${entry.duration.toFixed(0)} ms`);
console.log(`DNS: ${dns.toFixed(0)} ms`);
console.log(`TCP: ${tcp.toFixed(0)} ms`);
console.log(`TTFB: ${ttfb.toFixed(0)} ms`);
console.log(`Download: ${download.toFixed(0)} ms`);
console.groupEnd();
}
});
// buffered: true catches entries that happened before observer was created
observer.observe({ type: "resource", buffered: true });
// Long Tasks API: detect main-thread blocking > 50 ms
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long Task detected: ${entry.duration.toFixed(0)} ms`);
// entry.attribution gives the script/frame responsible
}
});
longTaskObserver.observe({ type: "longtask", buffered: true });
6. Long Tasks API: detecting jank and blocking time
A "Long Task" is defined as any JavaScript execution that blocks the main thread for longer than 50 ms. Long tasks prevent the browser from responding to user input, animations running smoothly, and scroll interactions feeling responsive, a phenomenon known as "jank". The Long Tasks API within the Performance API makes it possible to automatically detect and log such blocking code sections without developers having to manually instrument every code section.
Every long task entry contains an attribution array that provides information about the container (document, iframe, worker) in which the long task ran. Combined with User Timing marks, you can make precise statements: "This 120 ms long task ran during our loadUserData measure." That is the foundation for evidence-based performance optimization instead of guessing from DevTools screenshots.
7. Measuring Web Vitals directly with the Performance API
Web Vitals, LCP (Largest Contentful Paint), FID (First Input Delay), CLS (Cumulative Layout Shift), are the core metrics for web user experience defined by Google. All three can be measured directly via the Performance API, without needing to include the official web-vitals library. LCP is captured via the PerformanceObserver with type: "largest-contentful-paint". CLS with type: "layout-shift". FID with type: "first-input". This enables Real User Monitoring (RUM) directly in your own JavaScript code.
A complete RUM setup with the Performance API sends these metrics asynchronously to an analytics backend, via navigator.sendBeacon(), which is guaranteed to send even when the page is being unloaded. This is the core of Google Search Console, Lighthouse, and Chrome UX Report: real user data, measured in a real browser, via the natively built-in Performance API. Anyone using the web-vitals library can significantly reduce bundle weight with their own lean implementation built on the Performance API.
| API | Purpose | Entry Type | Key Property |
|---|---|---|---|
| performance.now() | Monotonic timestamp | (none) | Sub-ms, never negative |
| User Timing | Name code sections | mark, measure | DevTools integration, buffer query |
| Resource Timing | Network load times | resource | DNS, TCP, TTFB, download |
| Long Tasks API | Detect jank | longtask | duration > 50 ms, attribution |
| Layout Instability | Measure CLS | layout-shift | value, hadRecentInput |
8. Performance API in Node.js: perf_hooks and Worker Threads
In Node.js, the Performance API is available via the built-in module node:perf_hooks. performance.now() and PerformanceObserver work there with the same semantics as in the browser, but with higher precision, without the 0.1 ms security cap that browsers introduce for timing-attack protection. That makes Node.js-side measurements with the Performance API especially precise for micro-benchmarks and algorithm comparisons.
In Worker Threads, the Performance API is fully available. Each worker has its own time origin, relative to the worker's start. This enables independent runtime measurement in each thread, without measurements from the main thread and worker threads affecting one another. For benchmarks in Node.js, performance.timerify(fn) is also recommended, which transforms a function into a measured variant and automatically creates a function entry in the performance buffer for each call.
// Node.js Performance API via perf_hooks (same API as browsers)
import { performance, PerformanceObserver } from "node:perf_hooks";
// Wrap function to auto-instrument with User Timing
function withTiming(name, fn) {
return async function (...args) {
performance.mark(`${name}:start`);
try {
return await fn.apply(this, args);
} finally {
performance.mark(`${name}:end`);
performance.measure(name, `${name}:start`, `${name}:end`);
}
};
}
// Observer reports all measures to console
const obs = new PerformanceObserver((items) => {
items.getEntries().forEach(entry => {
console.log(`[perf] ${entry.name}: ${entry.duration.toFixed(3)} ms`);
});
obs.disconnect();
});
obs.observe({ type: "measure", buffered: true });
// timerify: auto-instrumented function (Node.js specific)
import { createHistogram } from "node:perf_hooks";
const histogram = createHistogram();
const timerified = performance.timerify(
function computeHash(data) { /* … */ },
{ histogram }
);
// After multiple calls: histogram.mean, histogram.percentile(99), etc.
Mironsoft
Web Performance Optimization and Real User Monitoring
Systematically improve Web Vitals and load times?
We implement Real User Monitoring with the native Performance API, identify Long Tasks and network bottlenecks, and deliver concrete optimization measures for LCP, CLS, and FID.
RUM Setup
Native Performance API RUM without external library: Web Vitals, Long Tasks, Resource Timing
Performance Audit
Analysis of LCP, CLS, TTFB, and Long Tasks based on real user data
Optimization
Concrete measures: preconnect, code splitting, CDN, caching, and bundle optimization
10. Summary
The JavaScript Performance API is the most complete native tool for runtime measurement in the browser and in Node.js. performance.now() delivers monotonic sub-millisecond timestamps without system clock dependency. User Timing with performance.mark() and performance.measure() makes your own code sections visible, directly in DevTools. The PerformanceObserver enables reactive, non-blocking capture of Resource Timing, Long Tasks, and Web Vitals. The Long Tasks API identifies sources of jank without manual instrumentation.
Combining these tools builds a complete Real User Monitoring system without an external library, delivering real user data for LCP, CLS, and FID. That is the foundation for evidence-based performance optimization, not based on lab values from Lighthouse, but based on Performance API data from real users under real network conditions.
JavaScript Performance API: the essentials at a glance
performance.now()
Monotonic, sub-millisecond, navigation-relative. Never negative, no system clock dependency. The basis of all performance measurement.
User Timing
performance.mark() and performance.measure() make code sections visible in DevTools and retrievable from the buffer.
PerformanceObserver
Reactive, non-blocking. Watches Resource Timing, Long Tasks, and Web Vitals asynchronously, ideal for RUM in production.
Long Tasks API
Detects jank: JS execution > 50 ms. Attribution array shows which script is blocking. No manual instrumentation needed.