Measuring Protocol Performance in the Field
You enabled HTTP/3 on the CDN, the Network panel says h3, and the synthetic test at 2 % packet loss is 300 ms faster. Then the field dashboard moves by 40 ms, in the wrong direction, and nobody can say whether that is the protocol, the traffic mix, or the week. Protocol changes are unusually hard to evaluate from production data because the thing you changed also changes who is measured: the population that successfully negotiates QUIC is not the population that falls back to TCP. This page covers the instrumentation that gets trustworthy protocol data out of real browsers — nextHopProtocol, the Timing-Allow-Origin gate, Server-Timing, sampling design and beacon delivery — and then the analysis discipline that separates a genuine transport win from a cohort artefact.
The mechanism side of the question is covered elsewhere on this site: why a lost packet stalls every stream under head-of-line blocking, and what the CDN edge has to be configured to do before a browser will even try QUIC. This page assumes the rollout happened and asks the next question: did it help, and by how much, for whom?
What the Browser Actually Tells You About the Protocol
The single authoritative field in the browser is PerformanceResourceTiming.nextHopProtocol, defined by Resource Timing Level 2. Its value is the ALPN protocol identifier negotiated for the connection that fetched the resource, taken verbatim from the IANA ALPN registry: h3 for HTTP/3 over QUIC, h2 for HTTP/2 over TLS, http/1.1, and occasionally spdy/3.1 on very old middleboxes. PerformanceNavigationTiming inherits the same attribute for the document request. Critically, the spec says the value is the empty string — not unknown, not http/1.1 — when the user agent cannot determine the protocol or is not allowed to expose it.
That last clause is where most protocol dashboards quietly break. For a cross-origin resource, every interesting field on the timing entry sits behind the timing-allow check. If the response carries no Timing-Allow-Origin header naming your page’s origin (or *), the browser still creates the entry — you get name, entryType, initiatorType, startTime and duration — but redirectStart/redirectEnd, domainLookupStart/domainLookupEnd, connectStart/connectEnd, secureConnectionStart, requestStart and responseStart are all forced to 0, transferSize/encodedBodySize/decodedBodySize are 0, and nextHopProtocol is "". A CDN that has not been told to send the header makes its own HTTP/3 rollout invisible to your telemetry.
Engine differences that change what you can measure
All three engines implement nextHopProtocol, and all three apply the same timing-allow gate, but they differ in what they report around the edges of the network — service workers, caches and interim responses — and those differences show up as protocol buckets that do not exist on the wire.
| Behaviour | Chromium | WebKit (Safari) | Gecko (Firefox) |
|---|---|---|---|
| Value for a service-worker-generated response | "", with workerStart > 0 |
"", with workerStart > 0 |
""; workerStart populated |
| Value for an HTTP cache hit | Protocol of the connection that revalidated, or "" when fully cached |
Frequently "" for memory-cache hits |
Protocol of the original fetch |
| Cache hits distinguishable without heuristics | Yes — deliveryType === "cache" |
No dedicated field; infer from transferSize === 0 |
Yes — deliveryType on recent releases |
Cross-origin serverTiming exposed |
Yes, with Timing-Allow-Origin |
Yes, with Timing-Allow-Origin |
Yes, with Timing-Allow-Origin |
| 103 interim response visible | Yes — firstInterimResponseStart |
Not exposed | Not exposed |
QUIC attempted before any Alt-Svc |
Yes, via an HTTPS/SVCB DNS record | Yes, via HTTPS record | Yes, via HTTPS record |
The practical consequence: on Safari a naive collector will attribute a large share of repeat-visit requests to a phantom "" protocol bucket that is really “served from memory cache”. Bucket those separately or your HTTP/2 baseline silently absorbs every fast cache hit and the comparison inverts.
Spec and API Reference
Fields that carry protocol information
| Field | Interface | Values | What it is for |
|---|---|---|---|
nextHopProtocol |
PerformanceResourceTiming, PerformanceNavigationTiming |
ALPN id: h3, h2, http/1.1, "" |
The only direct statement of which protocol carried this response |
connectStart / connectEnd |
Both | DOMHighResTimeStamp |
Handshake cost; equal values mean connection reuse or 0-RTT resumption |
secureConnectionStart |
Both | Timestamp or 0 |
On QUIC the crypto handshake is inside connect, so the gap to connectEnd is the whole 1-RTT setup |
deliveryType |
PerformanceResourceTiming |
"", "cache", "navigational-prefetch" |
Excludes responses that never touched a connection |
responseStatus |
Both | HTTP status integer | Drops 3xx/4xx noise out of a latency comparison |
firstInterimResponseStart |
PerformanceNavigationTiming |
Timestamp or 0 |
Time of the 103 response, so Early Hints does not distort TTFB |
serverTiming |
Both | PerformanceServerTiming[] |
Server-side split of the waiting phase |
transferSize |
Both | Bytes incl. headers, 0 when cached or TAO-gated |
Detects cache hits and header bloat |
Timing-Allow-Origin |
Response header | Origin list or * |
Unlocks every field above for cross-origin responses |
Server-Timing |
Response header | name;dur=…;desc=… |
The transport for server-side metrics into the browser |
Browser support
First stable release. Feature-detect at runtime rather than gating on a version string — 'deliveryType' in entry costs nothing and never goes stale.
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
PerformanceResourceTiming (Level 2) |
43+ | 35+ | 11+ |
nextHopProtocol |
43+ | 45+ | 15+ |
Timing-Allow-Origin enforcement |
43+ | 43+ | 11+ |
PerformanceServerTiming |
65+ | 61+ | 16.4+ |
deliveryType |
109+ | 130+ | not exposed |
responseStatus |
109+ | 133+ | not exposed |
firstInterimResponseStart |
123+ | not exposed | not exposed |
PerformanceObserver with buffered: true |
62+ | 57+ | 13+ |
navigator.sendBeacon |
39+ | 31+ | 11.1+ |
Step-by-Step Implementation
Step 1 — Unlock the timings at every asset host
Nothing else works until the browser is allowed to speak. Add Timing-Allow-Origin to every host that serves subresources: your CDN, your image host, your font host, and any third party whose latency you intend to attribute.
location /assets/ {
# Naming the page origin rather than "*" keeps the timings readable by your own
# RUM only. Without this header the browser still records an entry, but zeroes
# every phase timestamp AND nextHopProtocol — so an h3 rollout on this host
# cannot be distinguished from a failed one.
add_header Timing-Allow-Origin "https://www.example.com" always;
# Advertise the QUIC endpoint. The browser will not attempt h3 on a cold profile
# without this (or an HTTPS DNS record), so the FIRST connection of a session is
# h2 by construction — a fact the analysis in step 6 has to account for.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
}
Third-party hosts you do not control are the common blocker. If a vendor will not send the header, do not silently fold their entries into the h2 bucket — keep them in an explicit unknown bucket so the gap is visible on the dashboard as a measurement problem rather than as data.
Step 2 — Collect nextHopProtocol with a buffered observer
Use PerformanceObserver rather than performance.getEntriesByType, and always pass buffered: true. The resource timing buffer holds 250 entries by default and the earliest, most critical requests — the stylesheet, the LCP image — complete before your analytics script has parsed. A non-buffered observer misses exactly the requests you care about most.
// One sampling decision per page load, taken before anything is recorded.
// Sampling per ENTRY would bias the corpus: a gallery page emitting 400 requests
// would contribute 40x the rows of an article page, and the protocol mix of the
// heaviest pages would dominate every percentile you compute.
const SAMPLED = Math.random() < 0.10;
const MAX_ROWS = 120; // hard cap: a media page can emit 600+ entries
const rows = [];
function record(entry) {
if (!SAMPLED || rows.length >= MAX_ROWS) return;
// A cache hit never negotiated a protocol on this navigation. Its nextHopProtocol
// describes some earlier connection, so counting it as an h2/h3 sample imports
// last week's rollout state into today's comparison.
if (entry.deliveryType === 'cache') return;
if (entry.deliveryType === undefined && entry.transferSize === 0 &&
entry.decodedBodySize > 0) return; // Safari fallback heuristic
// "" is NOT http/1.1. It means TAO-gated, service-worker-served, or synthesised.
// Keeping it as its own bucket stops a missing header from inflating the h2 arm.
const protocol = entry.nextHopProtocol || 'unknown';
rows.push({
url: new URL(entry.name).pathname,
initiator: entry.initiatorType,
protocol,
// connectEnd === connectStart means the connection was reused or resumed at
// 0-RTT; a nonzero value is the handshake this navigation actually paid for.
connect_ms: Math.round(entry.connectEnd - entry.connectStart),
ttfb_ms: Math.round(entry.responseStart - entry.requestStart),
body_ms: Math.round(entry.responseEnd - entry.responseStart),
bytes: entry.transferSize
});
}
const po = new PerformanceObserver((list) => list.getEntries().forEach(record));
// buffered:true replays entries recorded before this script ran. Without it the
// render-blocking CSS and the LCP image — the two requests whose protocol matters
// most — are missing from every session.
po.observe({ type: 'resource', buffered: true });
po.observe({ type: 'navigation', buffered: true });
The companion guide on collecting nextHopProtocol with Resource Timing works through the buffer-overflow and single-page-app cases this snippet leaves out.
Step 3 — Split the waiting phase with Server-Timing
responseStart - requestStart is the browser’s whole view of the server. It contains edge routing, origin queueing, application compute and the first byte’s flight time, and a protocol change moves some of those and none of the others. Server-Timing is the only standard way to see inside it.
HTTP/3 200
content-type: text/html; charset=utf-8
timing-allow-origin: https://www.example.com
server-timing: edge;dur=3.4, q;dur=11.2;desc="origin queue", app;dur=94.0, cache;desc=MISS, arm;desc=h3
Read it back off the same entry the protocol came from, so every metric shares one row:
function serverMetrics(entry) {
// serverTiming is [] rather than undefined when the header is absent, and is
// gated by the SAME Timing-Allow-Origin check as the phase timestamps — so a
// cross-origin asset without TAO yields no server metrics either.
const out = {};
for (const m of entry.serverTiming || []) {
// dur defaults to 0 when the server sent only a desc; keep desc separately so
// a cache-state marker ("MISS") is not silently recorded as a 0 ms duration.
if (m.duration) out[`${m.name}_ms`] = m.duration;
if (m.description) out[`${m.name}_state`] = m.description;
}
return out;
}
With app;dur in hand, subtracting it from measured TTFB leaves the part of the wait that transport can actually influence. If a QUIC rollout moves total TTFB by 60 ms while app;dur is flat, the win is real transport; if app;dur moved too, something else changed with it. Reading Server-Timing headers for protocol stalls covers the header syntax and the stall signatures in detail.
Step 4 — Sample once, cap the payload, beacon on hide
function flush() {
if (document.visibilityState !== 'hidden' || rows.length === 0) return;
const nav = performance.getEntriesByType('navigation')[0];
const payload = JSON.stringify({
doc_protocol: nav ? nav.nextHopProtocol || 'unknown' : 'unknown',
// effectiveType and the device class are the two confounders that most often
// explain a protocol delta, so they travel WITH the sample, not as a join later.
net: navigator.connection ? navigator.connection.effectiveType : null,
cpu: navigator.hardwareConcurrency || null,
rows
});
// sendBeacon survives the page teardown that a fetch() would be cancelled by.
navigator.sendBeacon('/rum/protocol', new Blob([payload], { type: 'application/json' }));
rows.length = 0; // never double-count after a bfcache restore
}
// 'hidden' is the last event reliably delivered on mobile Safari and on
// bfcache-eligible navigations, where 'unload' never fires at all. Beaconing on
// 'unload' loses the slowest, most-abandoned sessions — precisely the tail a
// protocol comparison lives or dies on.
addEventListener('visibilitychange', flush, { capture: true });
addEventListener('pagehide', flush, { capture: true });
Step 5 — Randomise the assignment with an Alt-Svc holdback
This is the step almost everyone skips, and it is the one that makes the numbers mean something. Left alone, protocol assignment is chosen by the user’s network: browsers on networks that permit UDP/443 get h3, everyone behind a UDP-blocking proxy gets h2. Those two groups differ in office versus mobile, geography, device age and connection quality. Comparing them measures the networks, not the protocol.
Strip the advertisement for a stable, randomly chosen slice of sessions at the edge:
export default {
async fetch(request, env, ctx) {
const response = await fetch(request);
const out = new Response(response.body, response);
// A stable per-visitor hash, not Math.random(): the arm must survive across
// requests and page loads, otherwise a single session appears in both arms and
// the connection-reuse advantage leaks from one into the other.
const arm = armFor(request) < 0.10 ? 'h2-hold' : 'h3';
// Removing Alt-Svc is the only lever that reliably keeps a browser on TCP
// without breaking the site: there is no client-side switch, and blocking UDP
// at the edge would produce a handshake timeout rather than a clean fallback.
if (arm === 'h2-hold') out.headers.delete('Alt-Svc');
// Stamp the arm into a header the RUM collector already parses, so assignment
// and measurement come from the same response and cannot drift apart.
out.headers.append('Server-Timing', `arm;desc=${arm}`);
return out;
}
};
A 10 % holdback on a site doing 12 000 pageviews an hour yields roughly 1 200 control navigations an hour, which reaches a usable sample within a day. Note that a held-back visitor with a warm Alt-Svc cache entry keeps using QUIC until ma expires, so give the experiment a burn-in period at least as long as your max-age before you start counting. The Alt-Svc rollout guide covers the cache-lifetime mechanics.
Step 6 — Aggregate as distributions per arm
-- One row per navigation, arm and protocol taken from the SAME response.
-- Grouping by arm rather than by observed protocol is what keeps the comparison
-- randomised: a session that fell back mid-flight stays in the arm it was assigned.
SELECT
arm,
COUNT(*) AS n,
APPROX_QUANTILES(lcp_ms, 100)[OFFSET(50)] AS p50,
APPROX_QUANTILES(lcp_ms, 100)[OFFSET(75)] AS p75,
APPROX_QUANTILES(lcp_ms, 100)[OFFSET(95)] AS p95,
COUNTIF(observed_protocol = 'h3') / COUNT(*) AS h3_share
FROM rum_navigations
WHERE event_date BETWEEN '2026-07-20' AND '2026-07-26'
AND response_status = 200 -- redirects and errors have their own latency shape
AND delivery_type != 'cache' -- cache hits never negotiated anything
GROUP BY arm;
Reporting h3_share next to the percentiles is what turns an intention-to-treat comparison into an interpretable one: if only 62 % of the treatment arm actually got QUIC, the observed delta understates the per-user effect by roughly that factor.
Telling a Real Win From Noise
Three failure modes account for nearly every wrong protocol conclusion, and all three are visible in the same chart.
Selection. The h2 population is not a control group. It contains every corporate network that blocks UDP, every visitor whose first navigation predates the Alt-Svc cache entry, and every client on a stack too old to attempt QUIC. Those users tend to be slower for reasons that have nothing to do with the transport, which inflates the apparent QUIC win. Occasionally it runs the other way — office fibre blocks UDP while mobile allows it — and the win disappears entirely. The holdback in step 5 is the fix; nothing in the analysis layer substitutes for it.
Dispersion. Page-level metrics have a standard deviation on the order of their median. With σ ≈ 900 ms, the two-sample size formula n ≈ 2(z₁₋α₂ + z₁₋β)²σ² / Δ² gives about 1 270 navigations per arm to detect a 100 ms mean shift at 95 % confidence and 80 % power. Sample percentiles are noisier than means, so budget three to five times that — roughly 4 000 to 6 500 per arm — before a p75 delta is worth acting on. Below that threshold the dashboard is showing you the week, not the protocol.
Aggregation. Averages hide the entire effect. QUIC’s advantage is concentrated where loss and reordering occur, which is the upper end of the distribution; the median barely moves. A comparison that reports means will conclude “no effect” from data that contains a 1 000 ms improvement at p95.
A bootstrap confidence interval is the cheapest defensible way to put error bars on a percentile: resample the arm’s observations with replacement 1 000 times, recompute p75 for each resample, and take the 2.5th and 97.5th percentiles of those 1 000 values. If the two arms’ intervals overlap, you do not have a result yet. Do this once per week of data rather than once per day; daily percentile deltas on a normal site swing by 100–200 ms on traffic-mix changes alone.
Two supporting cuts make the story much harder to argue with. First, segment by navigator.connection.effectiveType: a genuine transport win grows monotonically as connection quality degrades, because loss and reordering grow with it. If your h3 advantage is largest on 4g and absent on 3g, the effect is probably not transport. Second, correlate with loss directly — the mechanics are in TCP vs QUIC loss recovery under packet loss — and check that the improvement concentrates in the sessions with the longest body_ms relative to their byte count, which is the field signature of a stalled transfer.
Verification Workflow
Confirm the header is actually there
# Both headers must be present on the ASSET host, not just on the document host.
curl -sSI --http3 https://cdn.example.com/assets/app.css \
| grep -iE 'timing-allow-origin|alt-svc|server-timing'
# Repeat over HTTP/2. A CDN rule attached only to the h3 listener is a common slip
# and produces a dataset where the control arm has no timings at all.
curl -sSI --http2 https://cdn.example.com/assets/app.css \
| grep -iE 'timing-allow-origin|server-timing'
Audit the live page from the console
Run this on the page you are instrumenting. It reports the protocol mix the way your collector will see it, including the unknown bucket that reveals missing headers.
// Counts entries by the exact value the collector will store, so a TAO gap shows
// up here as an "(gated)" row before it silently becomes an h2 row in production.
const mix = {};
for (const e of performance.getEntriesByType('resource')) {
const key = `${e.nextHopProtocol || '(gated)'} / ${e.deliveryType || 'network'}`;
mix[key] = (mix[key] || 0) + 1;
}
mix[`document: ${performance.getEntriesByType('navigation')[0].nextHopProtocol}`] = 1;
console.table(mix);
DevTools steps
- Open Network, right-click the column header, and enable Protocol and Connection ID. Reload with the cache disabled.
- Sort by Protocol. Any row showing
h2on a host you expect to beh3is either a first connection beforeAlt-Svcwas cached, a separate origin that has not been coalesced, or a UDP failure — checking the Connection ID column tells you which, and verifying connection coalescing walks through the distinction. - Click a request and open Timing. Compare Initial connection and SSL against the
connectEnd - connectStartyour collector recorded; a mismatch usually means the entry you sampled was a reused connection, not a fresh handshake. The network waterfall anatomy reference maps each DevTools row onto its Resource Timing field. - Filter the Network panel by
beacon(or by your collector path) and confirm exactly one request fires when you switch tabs — not zero, and not one pervisibilitychange. - For a stubborn
h3negotiation failure, capture a log withchrome://net-exportand search the resulting JSON forQUIC_CONNECTION_MIGRATIONand handshake-failure events; the Network panel only shows the outcome, not the reason.
Edge Cases and Gotchas
The first navigation of a session is almost always h2
Unless the origin publishes an HTTPS/SVCB DNS record, the browser learns about the QUIC endpoint only from an Alt-Svc header on a response it has already received over TCP. A cold profile therefore fetches the document over HTTP/2, then upgrades subresources. Any dashboard that reports “share of navigations on h3” will read low forever and look like a rollout failure. Report protocol share per request, and attribute page metrics using the protocol of the request that produced them.
Mid-session fallback puts one session in both buckets
If a QUIC connection fails after the handshake — network change, migration failure, a middlebox dropping UDP mid-flight — Chromium marks the origin broken and serves the rest of the session over TCP. A single page load can legitimately contain both h3 and h2 entries. Store the protocol per row rather than per session, and when you need a session-level label, derive it from the LCP resource rather than from a majority vote.
bfcache restores produce no new timing entries
A back-navigation restored from the back/forward cache does not create a new PerformanceNavigationTiming entry and issues no network requests, so it contributes nothing to protocol data — while still contributing to whatever Core Web Vitals library you run. If your protocol dashboard and your vitals dashboard disagree on session counts, this is usually why. Listen for pageshow with event.persisted === true and count those separately.
Service worker responses erase the protocol
When a fetch handler responds from caches.match() the entry’s nextHopProtocol is "" and workerStart is nonzero. When the handler falls through to the network, the value is populated normally. A site with an offline-first service worker can therefore show a collapsing h3 share after a service worker deploy with no transport change whatsoever. Split on workerStart > 0 before doing anything else.
103 Early Hints changes what TTFB means
Where 103 Early Hints is deployed, Chromium exposes firstInterimResponseStart and keeps responseStart on the final response. Other engines expose only responseStart. If you turn Early Hints on during a protocol experiment, the TTFB series changes meaning in one engine and not the others — hold interim-response features fixed for the duration of a protocol comparison, or the arms are no longer comparable.
Sampling drops the sessions you most need
Beacon delivery is best-effort. Sessions that crash, that are killed by the OS under memory pressure, or that the user force-closes may never flush. Those are disproportionately the slow, heavy sessions on weak devices — the same upper percentiles where QUIC’s advantage lives. Flush incrementally (a first beacon at LCP, a second at hide) rather than accumulating a single payload for the whole session, and treat any arm whose beacon-loss rate differs from the other’s as unusable.
Server-Timing is public and it costs bytes
Anything in Server-Timing is readable by any script on the page and by anyone with DevTools. Do not put internal hostnames, queue depths that reveal capacity, or user identifiers in it. Because dur values change on every response they never compress away in the HPACK or QPACK dynamic table, so each metric costs roughly its literal length on every response. Two- or three-character names and a sampled verbose mode keep that under control.
Percentiles do not average
A weekly p75 is not the mean of seven daily p75s, and a global p75 is not the mean of per-country p75s. If your pipeline stores pre-aggregated percentiles per hour, you cannot correctly roll them up. Store either the raw samples or a sketch structure (t-digest, KLL) that merges correctly, or every cross-segment comparison you make will be subtly wrong in a direction that depends on traffic mix.
FAQ
Why is nextHopProtocol an empty string for my CDN assets?
Almost always a missing Timing-Allow-Origin header. For a cross-origin response without it, the browser creates the entry but zeroes every phase timestamp, zeroes the three size fields, and reports nextHopProtocol as "". The other causes are a service-worker-generated response (check workerStart > 0) and a fully cached response on engines that do not expose deliveryType. Treat "" as its own bucket rather than folding it into http/1.1.
Does h3 on the document request mean the whole page used QUIC?
No. nextHopProtocol is per request. A first visit usually fetches the document over HTTP/2 because no Alt-Svc advertisement has been cached yet, then upgrades subresources on the next connection; a session that hits a QUIC failure can flip back to TCP part-way through. Attribute each metric using the protocol of the request that produced it — for LCP, the protocol of the LCP resource.
How many samples do I need to trust a 100 ms improvement?
For a mean shift of 100 ms against a per-user standard deviation near 900 ms, the standard two-sample calculation gives roughly 1 270 navigations per arm at 95 % confidence and 80 % power. Percentile estimates carry more variance than means, so plan on three to five times that — about 4 000 to 6 500 navigations per arm — before a p75 delta is actionable. Segmenting by country or device multiplies that requirement per segment.
Can synthetic testing replace field measurement here?
It can establish the mechanism but not the outcome. A synthetic run with injected loss demonstrates precisely how per-stream recovery behaves, with no cohort confounding. What it cannot reproduce is the real distribution of middleboxes, UDP-blocking networks, radio types and device classes that determines how much of your traffic ever receives the benefit. Use synthetic results to explain a field delta; use field data to size it.
Should I compare by assigned arm or by observed protocol?
By assigned arm, and report the observed protocol share alongside it. Grouping by observed protocol re-introduces exactly the selection problem the holdback was built to remove, because sessions that failed to negotiate QUIC move themselves into the control group. The arm-based comparison measures the effect of shipping the change; dividing it by the share that actually got h3 estimates the effect on a user who did.