Module 05: Performance Timing

In previous modules we collected what the user visited and what device they used. Now we measure how fast the page loaded. The Performance API gives us millisecond-precision timestamps for every phase of the page load — from DNS lookup through final paint — and a full inventory of every resource the browser fetched.

Demo Files

Run: Open test.html and check the console for timing data. The page also renders a visual breakdown of each timing phase.

Why Performance Data Matters

Real User Monitoring (RUM) measures actual user experience, not synthetic benchmarks. A page that loads in 200ms on your dev machine might take 8 seconds on a user's phone in a rural area. Synthetic tests (Lighthouse, WebPageTest) run on controlled hardware with fast networks — they measure potential performance. RUM measures actual performance.

Performance data tells you what real users experience. It answers questions that synthetic tests cannot:

Without RUM data, you are optimizing for your own machine, not your users' machines.

Navigation Timing API

The Navigation Timing API provides a PerformanceNavigationTiming entry that records timestamps for every phase of the page load. You access it via performance.getEntriesByType('navigation'), which returns an array with a single entry (the current page navigation).

Here is the timeline of events, from the moment the browser starts fetching the page to the final load event:

navigationStart | +----v--------+ | Redirect | redirectStart --> redirectEnd +-------------+ | DNS | domainLookupStart --> domainLookupEnd +-------------+ | TCP | connectStart --> connectEnd +-------------+ | TLS | secureConnectionStart --> connectEnd +-------------+ | Request | requestStart --> responseStart <-- TTFB +-------------+ | Response | responseStart --> responseEnd +-------------+ | DOM | domInteractive --> domComplete +-------------+ | Load | loadEventStart --> loadEventEnd +-------------+

Each row is a phase of the page load. The timestamps are all relative to timeOrigin (typically the moment the navigation started). By subtracting pairs of timestamps, we calculate the duration of each phase.

Building getNavigationTiming()

This function extracts the key performance milestones from the navigation entry:

function getNavigationTiming() {
  const entries = performance.getEntriesByType('navigation');
  if (!entries.length) return {};

  const n = entries[0];

  return {
    // DNS lookup time
    dnsLookup: round(n.domainLookupEnd - n.domainLookupStart),
    // TCP connection time
    tcpConnect: round(n.connectEnd - n.connectStart),
    // TLS handshake (HTTPS only)
    tlsHandshake: n.secureConnectionStart > 0
      ? round(n.connectEnd - n.secureConnectionStart) : 0,
    // Time to First Byte
    ttfb: round(n.responseStart - n.requestStart),
    // Download time (response)
    download: round(n.responseEnd - n.responseStart),
    // DOM interactive (HTML parsed, not all resources loaded)
    domInteractive: round(n.domInteractive - n.fetchStart),
    // DOM complete (all resources loaded)
    domComplete: round(n.domComplete - n.fetchStart),
    // Full page load
    loadEvent: round(n.loadEventEnd - n.fetchStart),
    // Total fetch time
    fetchTime: round(n.responseEnd - n.fetchStart),
    // Transfer size and header overhead
    transferSize: n.transferSize,
    headerSize: n.transferSize - n.encodedBodySize
  };
}

function round(n) {
  return Math.round(n * 100) / 100;
}

Each calculated value represents a meaningful milestone:

Property What It Measures
dnsLookup Time to resolve the domain name to an IP address
tcpConnect Time to establish the TCP connection (3-way handshake)
tlsHandshake Time for TLS negotiation on HTTPS connections
ttfb Time to First Byte — server processing + network latency
download Time to download the HTML response body
domInteractive Time until the HTML is parsed and the DOM is ready (subresources may still be loading)
domComplete Time until all subresources (images, CSS, scripts) are loaded
loadEvent Total time from fetch start through load event completion
fetchTime Total time to fetch the HTML document (request + response)
transferSize Total bytes transferred including headers (after compression)
headerSize Overhead from HTTP response headers (transferSize - encodedBodySize)
Cross-Reference: Compare this to the reference collector.js getNavigationTiming() (line 148). The reference version calculates the same milestones: fetchTime, totalTime, downloadTime, timeToFirstByte, dnsLookupTime, headerSize. Our function uses slightly shorter property names, but the underlying calculations are identical.

Resource Timing API

While Navigation Timing tells you about the page load, Resource Timing tells you about every resource the page loaded: scripts, stylesheets, images, fonts, XHR/fetch requests, and more. Each resource gets its own PerformanceResourceTiming entry with the same timestamp properties as navigation timing.

Sending every individual resource entry to your analytics server would be expensive and noisy. Instead, we aggregate resources by type and compute summary statistics:

Building getResourceSummary()

function getResourceSummary() {
  const resources = performance.getEntriesByType('resource');

  const summary = {
    script:         { count: 0, totalSize: 0, totalDuration: 0 },
    link:           { count: 0, totalSize: 0, totalDuration: 0 },  // CSS
    img:            { count: 0, totalSize: 0, totalDuration: 0 },
    font:           { count: 0, totalSize: 0, totalDuration: 0 },
    fetch:          { count: 0, totalSize: 0, totalDuration: 0 },
    xmlhttprequest: { count: 0, totalSize: 0, totalDuration: 0 },
    other:          { count: 0, totalSize: 0, totalDuration: 0 }
  };

  resources.forEach((r) => {
    const type = summary[r.initiatorType] ? r.initiatorType : 'other';
    summary[type].count++;
    summary[type].totalSize += r.transferSize || 0;
    summary[type].totalDuration += r.duration || 0;
  });

  return {
    totalResources: resources.length,
    byType: summary
  };
}

The initiatorType property tells you what caused the resource to load. Common values:

initiatorType Source
script <script src="...">
link <link rel="stylesheet" href="...">
img <img src="...">
font @font-face declarations in CSS
fetch fetch() API calls
xmlhttprequest XMLHttpRequest calls

For each type, we track three things: how many resources loaded (count), how many bytes were transferred (totalSize), and how long they took combined (totalDuration). This gives you a bandwidth and latency profile broken down by resource category.

When to Collect Timing Data

Timing data is not available immediately. Navigation Timing only completes after the load event fires. Resource Timing accumulates as resources load. If you try to read loadEventEnd before the load event finishes, it will be 0.

Important: You must wait for the load event before collecting navigation timing. Even then, loadEventEnd is not populated until after the load event handler returns. A setTimeout with a delay of 0 ensures the browser has finished writing all the timing entries.
// Collect after the page is fully loaded
window.addEventListener('load', () => {
  // Small delay to ensure loadEventEnd is populated
  setTimeout(() => {
    const timing = getNavigationTiming();
    const resources = getResourceSummary();
    // Add to beacon payload...
  }, 0);
});

The setTimeout(fn, 0) pattern is not a hack — it is the correct way to read loadEventEnd. The load event handler runs during the load event; loadEventEnd is only set after all load handlers have completed. The setTimeout defers our read to the next task, by which point the value is populated.

Integrating with the Collector

Our collector-v4.js extends the previous collector by adding performance timing data to the beacon payload. The structure follows the same IIFE pattern, with the new timing functions integrated into the collect() function:

function collect() {
  const payload = {
    // ... previous fields (url, title, referrer, timestamp, session, technographics)
    url: window.location.href,
    title: document.title,
    referrer: document.referrer,
    timestamp: new Date().toISOString(),
    type: 'pageview',
    session: getSessionId(),
    technographics: getTechnographics(),
    timing: getNavigationTiming(),
    resources: getResourceSummary()
  };

  send(payload);
}

The key difference from previous versions: we collect timing data inside the load event with a setTimeout delay, rather than firing immediately on DOMContentLoaded. This ensures all timing milestones are available when we build the payload.

Understanding the Data

Here is a sample timing output and what each value means in practical terms:

{
  "dnsLookup": 0,
  "tcpConnect": 0,
  "tlsHandshake": 0,
  "ttfb": 12.5,
  "download": 3.2,
  "domInteractive": 245.8,
  "domComplete": 1823.4,
  "loadEvent": 1825.1,
  "fetchTime": 15.7,
  "transferSize": 45230,
  "headerSize": 430
}

Reading this data:

Note: DNS, TCP, and TLS values are often 0 for same-origin requests because the connection is already established. These values become meaningful for first visits to a site, or for cross-origin resources where the browser must establish a new connection. When aggregating RUM data across many users, you will see non-zero values from users making their first connection to your server.

Cross-Reference: The Reference Collector

Reference Implementation: The reference collector.js uses getNavigationTiming() (line 148) and initResourceTiming() (line 313) for the same data we collect here. The reference version also tracks resource consumption over time using a PerformanceObserver — we will add that capability in Module 06. See analytics-overview.html Section 11 (Performance Analytics) for the conceptual foundations behind performance measurement.

Summary