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.
Run: Open test.html and check the console for timing data. The page also renders a visual breakdown of each timing phase.
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.
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:
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.
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) |
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.
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:
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.
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.
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.
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.
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:
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.
setTimeout(fn, 0) to ensure loadEventEnd is populated