Lab data is not enough: measuring real-world load performance

Network panel throttling in browser developer tools and Lighthouse are convenient for establishing a consistent baseline when testing optimizations. However, these are synthetic — they produce lab data that doesn't reflect how your site actually performs for users on varied connections and devices. To evaluate loading performance as real visitors experience it, you need field data from the Navigation Timing and Resource Timing APIs.

These two APIs are closely related but measure distinct request types. Navigation Timing captures timings for HTML document requests (navigations), while Resource Timing measures requests for dependent resources like CSS, JavaScript, and images. Both report into the performance entry buffer, retrievable via performance.getEntriesByType:

// Get Navigation Timing entries:
performance.getEntriesByType('navigation');

// Get Resource Timing entries:
performance.getEntriesByType('resource');

The method takes a string, where 'navigation' or 'resource' returns the relevant set of timing entries.

Anatomy of a network request

You can reconstruct the timeline of a network request using the granular timestamps exposed by these APIs. Values are DOMHighResTimestamp instances, with precision down to microseconds or rounded to milliseconds depending on the browser.

Network timings as shown in Chrome's DevTools. The timings depicted are for request queueing, connection negotiation, the request itself, and the response in color-coded bars.
A visualization of a network request in the network panel of Chrome DevTools

DNS lookup

Before any connection is made, the domain must be resolved to an IP address via DNS. The domainLookupStart and domainLookupEnd properties define the bounds of this phase. Duration is a simple subtraction:

// Measuring DNS lookup time
const [pageNav] = performance.getEntriesByType('navigation');
const totalLookupTime = pageNav.domainLookupEnd - pageNav.domainLookupStart;

Connection and TLS negotiation

Establishing a connection to the server — including TLS negotiation over HTTPS — is another significant latency component captured through connectStart, secureConnectionStart, and connectEnd. Notably, secureConnectionStart may be 0 when HTTPS is not used or when a persistent connection is reused. Measure TLS time carefully with that in mind:

// Quantifying total connection time
const [pageNav] = performance.getEntriesByType('navigation');
const connectionTime = pageNav.connectEnd - pageNav.connectStart;
let tlsTime = 0; // <-- Assume 0 to start with

// Was there TLS negotiation?
if (pageNav.secureConnectionStart > 0) {
  // Awesome! Calculate it!
  tlsTime = pageNav.connectEnd - pageNav.secureConnectionStart;
}

Request and response phases

Once the connection is set up, factors such as server-side processing time, bandwidth, and resource size dictate request and response duration. Some of these cause latency outside your direct control; others are tied to your architecture and resource optimization. The timings describing this phase are:

  • fetchStart — when the browser begins checking caches (including HTTP and Cache instances) prior to making the actual request.
  • workerStart — when a service worker's fetch event handler starts handling the request, or 0 when no service worker controls the page.
  • requestStart — when the browser initiates the request.
  • responseStart — when the first response byte arrives.
  • responseEnd — when the last response byte arrives.

These allow you to measure distinct aspects of performance, such as cache lookups within a service worker or overall download time:

// Cache seek plus response time of the current document
const [pageNav] = performance.getEntriesByType('navigation');
const fetchTime = pageNav.responseEnd - pageNav.fetchStart;

// Service worker time plus response time
let workerTime = 0;

if (pageNav.workerStart > 0) {
  workerTime = pageNav.responseEnd - pageNav.workerStart;
}

Other request-response latency metrics can be derived similarly:

const [pageNav] = performance.getEntriesByType('navigation');

// Request time only (excluding redirects, DNS, and connection/TLS time)
const requestTime = pageNav.responseStart - pageNav.requestStart;

// Response time only (download)
const responseTime = pageNav.responseEnd - pageNav.responseStart;

// Request + response time
const requestResponseTime = pageNav.responseEnd - pageNav.requestStart;

Additional timings to explore

Beyond the basics, other value-bearing timings include:

  • Page redirects: redirectStart, redirectEnd, and redirectCount expose latency from HTTP-to-HTTPS hops and uncached 301 redirects.
  • Document unloading: unloadEventStart and unloadEventEnd capture delay caused by code running in an unload event handler before navigation proceeds.
  • Document processing: For sites sending very large HTML payloads, watch domInteractive, domContentLoadedEventStart, domContentLoadedEventEnd, and domComplete.

Using PerformanceObserver to collect timings

Although performance.getEntriesByType, performance.getEntriesByName, and performance.getEntries are adequate for lightweight analysis, heavy use can introduce excessive main thread work by scanning large numbers of entries or repeatedly polling the buffer. The recommended mechanism is a PerformanceObserver, which delivers entries as they are added:

// Create the performance observer:
const perfObserver = new PerformanceObserver((observedEntries) => {
  // Get all resource entries collected so far:
  const entries = observedEntries.getEntries();

  // Iterate over entries:
  for (let i = 0; i < entries.length; i++) {
    // Do the work!
  }
});

// Run the observer for Navigation Timing entries:
perfObserver.observe({
  type: 'navigation',
  buffered: true
});

// Run the observer for Resource Timing entries:
perfObserver.observe({
  type: 'resource',
  buffered: true
});

While this asynchronous pattern may feel less direct, it avoids occupying the main thread with non-critical work.

Sending data to a collector

Once aggregated, timings can be shipped to a backend endpoint using navigator.sendBeacon or a fetch call with the keepalive option set. Both approaches trigger a request that is non-blocking and queued to outlive the current page session if necessary:

// Check for navigator.sendBeacon support:
if ('sendBeacon' in navigator) {
  // Caution: If you have lots of performance entries, don't
  // do this. This is an example for illustrative purposes.
  const data = JSON.stringify(performance.getEntries());

  // Send the data!
  navigator.sendBeacon('/analytics', data);
}

The JSON string will arrive as a POST payload that can be decoded, processed, and stored for analysis.

Making sense of field data

With field timings collected, careful data analysis is critical. A few rules of thumb apply:

  • Avoid relying on averages — outliers make them unrepresentative of any single user's experience.
  • Use percentiles. For time-based metrics, lower is better, so focusing on low percentiles only accounts for your fastest experiences.
  • Prioritize the long tail — analyzing experiences at the 75th percentile or higher directs attention to the slowest users.

This guide is an introduction, not an exhaustive reference. The Navigation Timing Spec, Resource Timing Spec, ResourceTiming in Practice, and the MDN docs on both APIs offer more depth. Using these APIs equips you to understand how loading performance feels to actual users, making on-the-ground diagnostics and fixes far more reliable.