80. Station walkthrough: from URL to bytes
Your page's real timeline
Navigation Timing (§25) records every phase of loading the page you are on. The station turns it into an animated bar chart:
const n = performance.getEntriesByType("navigation")[0];
const phases = [
["Redirects", n.redirectEnd - n.redirectStart],
["Service worker / cache", n.domainLookupStart - n.fetchStart],
["DNS lookup", n.domainLookupEnd - n.domainLookupStart],
["TCP connect", (n.secureConnectionStart || n.connectEnd) - n.connectStart],
["TLS handshake", n.secureConnectionStart ? n.connectEnd - n.secureConnectionStart : 0],
["Request → first byte", n.responseStart - n.requestStart],
["Download HTML", n.responseEnd - n.responseStart],
["Parse → DOMContentLoaded", n.domContentLoadedEventEnd - n.responseEnd],
];
On localhost DNS and TCP take about 0 ms, and TLS takes under 1 ms. The
note under the chart explains that on a real site those phases are often
50–300 ms each, which is the whole argument for connection reuse,
preconnect and HTTP/3.
The experiment: 18 requests at once
The station fires N parallel fetches at /api/delay?ms=600, which waits
600 ms on the server. It then draws a waterfall from the browser's own
Resource Timing entries:
await Promise.all(Array.from({ length: n }, (_, i) =>
fetch(`/api/delay?ms=${delay}&id=${i}&run=${run}`, { cache: "no-store" }).then((r) => r.json())));
const entries = performance.getEntriesByType("resource").filter((e) => e.name.includes(`run=${run}`));
// per request: queued/stalled = requestStart - startTime, TTFB = responseStart - requestStart,
// download = responseEnd - responseStart, protocol = e.nextHopProtocol, server time = e.serverTiming[0]
The result over each protocol (real runs, 18 requests, 600 ms each):
HTTP/1.1 (:8088) |
HTTP/2 (:8443) |
|
|---|---|---|
| Wall time | ~1.8 s: three waves | ~608 ms: one burst |
| Connections | 6 (the per-origin limit) | 1, with streams multiplexed |
| Waterfall | a staircase with grey "queued" segments | 18 bars that start together |
The server adds a Server-Timing header, so the waterfall can show "server
time 601 ms" without any extra API, the same way you'd expose database time
to DevTools in production. The lane view guesses connection reuse by
assigning each request to the first connection that was free when it
started. For HTTP/2 there is only one lane.
TLS 1.3 vs 1.2, step by step
The handshake animation plays the real message sequence. TLS 1.3 sends its key share in the very first message, so the handshake finishes in one round trip, and the certificate travels encrypted. TLS 1.2 needs two round trips and sends the certificate in the clear. The station's "What did we negotiate?" panel asks the server what it actually saw:
// server.mjs — /api/whoami
function connectionInfo(req) {
const socket = req.stream?.session?.socket ?? req.socket; // h2: the session's TLS socket
return {
httpVersion: req.httpVersion, // "2.0" or "1.1"
alpn: socket.alpnProtocol || null, // "h2"
tls: socket.getProtocol?.() ?? null, // "TLSv1.3"
cipher: socket.getCipher?.()?.name ?? null, // e.g. "TLS_AES_256_GCM_SHA384"
headers: req.headers, // includes :method/:path pseudo-headers, sec-fetch-*, priority
};
}
You see the HTTP/2 pseudo-headers (:method, :authority, :path),
Fetch Metadata (sec-fetch-site, which §67 uses for CSRF), and
priority: u=1, i, the RFC 9218 priority signal browsers send with each
request.