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.

One CDN response as PerformanceResourceTiming reports it A 256 millisecond phase strip for an HTTP/3 asset: DNS 0 to 12 ms, UDP setup 12 to 28 ms, QUIC crypto handshake 28 to 72 ms, server wait 72 to 160 ms, body transfer 160 to 256 ms. Below it, the same request without a Timing-Allow-Origin header collapses to a single opaque bar exposing only duration, with every phase timestamp reading zero and nextHopProtocol an empty string. One CDN response, as PerformanceResourceTiming reports it h3 asset with Timing-Allow-Origin: every phase pair is populated DNS UDP QUIC crypto server wait (TTFB) body transfer 0 12 28 72 160 256 ms The same asset from a host that sends no Timing-Allow-Origin duration 256 ms only: phase timestamps 0, nextHopProtocol "" What the handshake costs connectEnd - connectStart = 60 ms on the first h3 request, 0 ms once resumed. What the header gates No TAO: nextHopProtocol, transferSize and all six phase pairs read 0.

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 });
Field pipeline for protocol telemetry, sized for 12 000 pageviews per hour Six stages in two rows. Top row, left to right: a PerformanceObserver on resource entries, a filter and sampler that drops cache deliveries, and a capped session buffer. A vertical arrow drops to the bottom row, which runs right to left: navigator.sendBeacon on visibilitychange, a collector storing protocol, network and device dimensions, and finally percentile rollups per arm. Field pipeline at 12 000 pageviews an hour, 1 session in 10 sampled PerformanceObserver resource + navigation Filter and sample drop deliveryType cache Session buffer cap 120 entries entries kept on hide sendBeacon visibilitychange Collector protocol, net, device Percentile rollups p75 and p95 per arm JSON rollup About 33 beacons a minute; h2 and h3 rows land in one table so a query can pair them. An unsampled session costs one observer callback and sends nothing.

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.

LCP by negotiated protocol across three percentiles Paired horizontal bars for h2 and h3 at p50, p75 and p95 over seven days and 84 210 navigations. At p50 the gap is 110 milliseconds, at p75 it is 260 milliseconds, and at p95 it widens to a full second, showing that the QUIC advantage sits in the upper percentiles rather than the median. LCP by negotiated protocol, 84 210 navigations over 7 days h2, n = 41 302 h3, n = 42 908 h2 1 820 ms h3 1 710 ms h2 2 640 ms h3 2 380 ms h2 4 900 ms h3 3 900 ms p50 p75 p95 -110 ms -260 ms -1 000 ms 0 1 s 2 s 3 s 4 s 5 s 6 s The median moves 110 ms; p95 moves a full second. Reporting the mean would call this flat. 39 % of the h2 rows come from networks that block UDP/443, so treat this as a lower bound.

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

  1. Open Network, right-click the column header, and enable Protocol and Connection ID. Reload with the cache disabled.
  2. Sort by Protocol. Any row showing h2 on a host you expect to be h3 is either a first connection before Alt-Svc was 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.
  3. Click a request and open Timing. Compare Initial connection and SSL against the connectEnd - connectStart your 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.
  4. 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 per visibilitychange.
  5. For a stubborn h3 negotiation failure, capture a log with chrome://net-export and search the resulting JSON for QUIC_CONNECTION_MIGRATION and 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.