Collecting nextHopProtocol with Resource Timing
Symptom: your field dashboard reports 38 % of requests on an unknown protocol and only 18 % on h3, while the DevTools Protocol column on the very same page load shows h3 on every row.
Root cause: the entry is queued long before your collector exists
A PerformanceResourceTiming entry is created when the response finishes, and it is delivered through two independent channels. The first is the performance entry buffer — a bounded store that performance.getEntriesByType('resource') reads, holding 250 resource entries by default per Resource Timing Level 2. The second is PerformanceObserver: each entry is queued to every observer whose entryTypes match, and the callback runs as a task some time after the entry was created. Those channels have different failure modes, and a typical analytics bundle manages to hit both at once.
The observer channel fails on registration time. An observer created without buffered: true receives only entries queued after observe() returns. Real collectors do not run early: a bundle marked defer executes after parsing, a tag-manager container adds a network hop of its own, and a collector imported after hydration lands even later. On a mid-tier phone, 2 400–3 000 ms is an ordinary attach time. Everything the browser fetched to get the first paint on screen — the document, the render-blocking stylesheet, the LCP image — completed hundreds of milliseconds earlier and is simply not offered to the callback.
That loss is not random, which is what makes it dangerous. The early requests are the ones that pay for a fresh connection, and on a cold profile the first connection is HTTP/2 by construction: the browser has not yet seen the Alt-Svc advertisement that would let it try QUIC, a sequencing detail covered in rolling out Alt-Svc headers safely. Dropping the first 200 entries of a navigation therefore removes most of the h2 rows and every handshake measurement, then leaves behind a tail of reused-connection image fetches. The dashboard is not a noisy version of the truth; it is a different population.
The buffer channel fails on capacity. When the 251st resource entry arrives, the user agent drops it, fires resourcetimingbufferfull on performance exactly once, and carries on. Nothing throws, nothing warns in the console, and getEntriesByType('resource').length sits at a suspiciously round 250. Crucially, buffered: true replays from that same buffer — so an overflowed buffer means an incomplete replay as well. On a single-page app the buffer is never reset by a route change, because there is no new document, so entries accumulate for the entire session and the ceiling is reached after two or three views.
The empty string is not http/1.1
The second half of the symptom is the unknown bucket. Resource Timing Level 2 says nextHopProtocol is the ALPN identifier of the connection that fetched the resource, and the empty string when the user agent cannot determine it or is not permitted to expose it. Three distinct conditions produce that empty string, and collapsing them into one bucket — or worse, defaulting them to http/1.1 — is what turns a header misconfiguration into an apparent protocol regression.
The dominant cause is the timing-allow check. For a cross-origin response with no Timing-Allow-Origin header naming your page’s origin, the entry still exists but every phase timestamp is forced to 0, transferSize, encodedBodySize and decodedBodySize are 0, and nextHopProtocol is "". The second is a service worker: when a fetch handler resolves from caches.match() no connection was used, so the value is empty and workerStart is non-zero. The third is a cache hit that never touched the network — Chromium and recent Gecko expose deliveryType === "cache", WebKit does not, so there you infer it from transferSize === 0 with a non-zero decodedBodySize.
Minimal reproduction
Two files. The page fetches 221 subresources from a CDN host; the collector is a normal deferred bundle. Nothing here is exotic, which is the point — this is the default shape of a RUM integration.
<!-- Reproduction. The collector is deferred, so it executes after the parser
finishes — around 2 700 ms on a throttled phone. Every request that mattered
for first paint has already produced its entry by then, and a non-buffered
observer is never offered a queued entry retroactively. -->
<link rel="stylesheet" href="https://cdn.example.com/app.css">
<img src="https://cdn.example.com/hero.avif" fetchpriority="high" alt="">
<!-- …216 more gallery images from the same host… -->
<script src="/rum.js" defer></script>
// rum.js — the bug, in eight lines.
const seen = [];
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries()) seen.push(e.nextHopProtocol || 'unknown');
});
// No `buffered` flag: observe() only subscribes to FUTURE entries. The spec queues
// an entry to observers at the moment it is created, and there is no replay unless
// you ask for one — so every response that finished before this line ran is lost.
po.observe({ entryTypes: ['resource'] });
addEventListener('pagehide', () => {
// getEntriesByType reads the BUFFER, which is a different store with a different
// failure mode. Printing both side by side is the fastest way to see the two bugs.
const buffered = performance.getEntriesByType('resource');
console.log('observer saw', seen.length, 'buffer holds', buffered.length);
});
On the gallery page that logs observer saw 7 buffer holds 250. Two independent losses in one line: the observer missed 214 entries because it attached late, and the buffer silently discarded everything past the 250th. Of the 250 that survive, 84 report nextHopProtocol === '' because the image host sends no Timing-Allow-Origin — which is how a page that ran entirely over QUIC arrives at the dashboard as 38 % unknown and 18 % h3.
Deterministic fix protocol
Steps 1 and 2 must run before the first response lands, which means an inline <head> script — no bundle, no tag manager. Everything after that can live in the deferred collector.
- [ ] 1. Register with
buffered: true, and observenavigationtoo. The flag replays the entry buffer into your callback, which is the only way a late collector sees the stylesheet and the LCP image. Observenavigationin the same collector so the document’s own protocol arrives on the same code path. - [ ] 2. Raise the ceiling from an inline head script.
performance.setResourceTimingBufferSize(600)before the parser reaches the first<link>. Also registerresourcetimingbufferfull— not to react, but to stamp atruncated: trueflag on the beacon so you can exclude those sessions rather than silently under-count them. - [ ] 3. Classify the empty string; never coerce it. Branch on
workerStart > 0, thendeliveryType === 'cache'(with thetransferSize === 0 && decodedBodySize > 0fallback for WebKit), then treat the remainder as timing-allow-gated. Four buckets, not one. - [ ] 4. Close the
Timing-Allow-Origingap the audit exposes. Every host in the gated bucket needs the header; the ones you cannot control stay in an explicitgatedbucket so the hole shows on the dashboard as a measurement defect. The parent topic’s field measurement setup has the header configuration. - [ ] 5. Call
clearResourceTimings()after every flush. This is what keeps a single-page app inside the ceiling. Clear only after the rows have been copied out, because the call empties the store the replay would have used. - [ ] 6. Store the protocol per row, not per session. A cold navigation is
h2for the document andh3for its subresources, and a mid-session QUIC failure moves the remainder back to TCP. A session-level protocol label is a lossy summary of a per-request fact. - [ ] 7. Cap the payload, drain with
takeRecords(), beacon onvisibilitychange. TaketakeRecords()before sending so entries queued since the last callback are not stranded in the observer, and usesendBeaconso the flush survives page teardown. - [ ] 8. Verify against DevTools on the same load. Count entries by the exact value the collector will store and compare with the Protocol column. The two must agree row for row; if they do not, one of steps 1–5 is not in effect.
The head script is four lines and has to be inline, because a fetched script cannot run before the responses it is meant to count:
<script>
// Runs before the parser reaches the first <link>, which is the only window in
// which the ceiling can be raised: entry 251 is discarded at creation time and
// is not recoverable by any consumer afterwards.
performance.setResourceTimingBufferSize(600);
// Fires at most once per document. Recording it as a flag lets the pipeline drop
// truncated sessions instead of treating a 250-row session as a complete one.
window.__rumTruncated = false;
addEventListener('resourcetimingbufferfull', () => { window.__rumTruncated = true; });
</script>
And the collector itself:
const rows = [];
const MAX_ROWS = 200; // one beacon must stay under ~60 KB
function bucket(entry) {
// Order matters: a service-worker response can also have transferSize 0, so the
// worker test has to run first or cache and worker rows blur into each other.
if (entry.workerStart > 0 && !entry.nextHopProtocol) return 'service-worker';
if (entry.deliveryType === 'cache') return 'cache';
if (entry.deliveryType === undefined &&
entry.transferSize === 0 && entry.decodedBodySize > 0) return 'cache';
// Everything still empty here reached the network but was hidden by the
// timing-allow check — a header defect, NOT an http/1.1 observation.
return entry.nextHopProtocol || 'gated';
}
const po = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (rows.length >= MAX_ROWS) break;
rows.push({
host: new URL(e.name).host,
init: e.initiatorType,
proto: bucket(e),
// connectEnd === connectStart means a reused or 0-RTT-resumed connection.
// Keeping it lets the analysis separate handshake cost from transfer cost
// instead of averaging a cold connection with 200 warm ones.
conn: Math.round(e.connectEnd - e.connectStart),
ttfb: Math.round(e.responseStart - e.requestStart)
});
}
});
// buffered:true replays the entry buffer; observing both types in one call keeps
// the document's protocol on the same code path as its subresources'.
po.observe({ type: 'resource', buffered: true });
po.observe({ type: 'navigation', buffered: true });
function flush() {
if (document.visibilityState !== 'hidden' || rows.length === 0) return;
// takeRecords() drains entries queued since the last callback — without it the
// final few requests of the session are stranded inside the observer.
po.takeRecords().forEach(() => {});
const body = JSON.stringify({ truncated: window.__rumTruncated, rows });
navigator.sendBeacon('/rum/protocol', new Blob([body], { type: 'application/json' }));
rows.length = 0;
// Only safe AFTER the copy: this empties the store that buffered:true replays from,
// and it is what stops a five-view session from hitting the ceiling at route 1.
performance.clearResourceTimings();
}
addEventListener('visibilitychange', flush, { capture: true });
addEventListener('pagehide', flush, { capture: true });
Verify with a console audit on the same load, then compare it against the Protocol column in the Network panel — the counting technique is the same one used for verifying connection coalescing with DevTools:
// Groups by host AND bucket, because a gated bucket is always one misconfigured
// host rather than a site-wide problem — this prints the host to go and fix.
const mix = {};
for (const e of performance.getEntriesByType('resource')) {
const k = `${new URL(e.name).host} → ${e.nextHopProtocol || '(empty)'}`;
mix[k] = (mix[k] || 0) + 1;
}
console.table(mix);
Before and after
Same gallery page, same CDN, same day. The only changes are steps 1–5; no protocol setting moved.
| Metric | Before | After | Delta |
|---|---|---|---|
| Entries offered to the observer | 7 | 221 | +214 |
| Entries surviving the buffer (5-view session) | 250 | 748 | +498 |
| Rows reaching the collector per navigation | 7 | 221 | 31× |
unknown / gated share |
38 % | 2 % | −36 pts |
Measured h3 share |
18 % | 77 % | +59 pts |
Measured h2 share |
44 % | 21 % | −23 pts |
Navigations with a handshake sample (connectEnd > connectStart) |
3 % | 61 % | +58 pts |
p75 TTFB, h3 rows |
214 ms | 268 ms | +54 ms |
| Beacon size per navigation | 1.1 KB | 14.6 KB | +13.5 KB |
Read the TTFB row carefully, because it is the one that looks like a regression and is not. Before the fix the collector only saw the last handful of gallery images, all riding a warm connection; the fix admits the document and the early subresources, which paid for a handshake and for a cold origin. The p75 rose 54 ms because the sample finally includes the requests that were always slow. This is the normal shape of a measurement fix: the number gets worse and the number gets true, and any percentile compared across the change boundary is comparing two different populations. Re-baseline on the day you deploy the collector, and hold protocol experiments until the new series has a week behind it.
The h3 share moving from 18 % to 77 % is likewise not a rollout result. It is the same traffic, correctly labelled. If you are attributing a page metric rather than counting requests — for LCP, the protocol of the LCP resource — pair this with the server-side split described in reading Server-Timing headers for protocol stalls, so a change in transport can be told apart from a change in origin compute.
FAQ
Does buffered: true replay entries the buffer already dropped?
No. The flag replays whatever is currently in the performance entry buffer, and that is the same bounded store getEntriesByType reads. An entry discarded for overflow is gone for every consumer, permanently. That is why setResourceTimingBufferSize() has to run from an inline <head> script rather than from the collector: by the time a deferred bundle executes, the 251st response may already have been dropped, and no later call can bring it back. The resourcetimingbufferfull event is the only signal that it happened.
Why does clearResourceTimings() not break my buffered observer?
Because clearing affects the buffer, not the subscription. A PerformanceObserver that is already registered keeps receiving new entries through its callback whatever the buffer contains; what you give up is the ability to replay history into an observer registered after the clear. So the ordering in step 5 is load-bearing — copy the rows out, send them, then clear. Reversing those two lines is the most common way a single-page-app collector loses its second route change.
Can one page load legitimately report both h2 and h3?
Yes, and on a cold profile it is the expected result. The document is fetched over TCP because no Alt-Svc advertisement has been cached yet, subresources upgrade to QUIC on the connection that follows, and a QUIC failure part-way through marks the origin broken so the remainder falls back to TCP. Store the protocol on each row, report protocol share per request, and when a session-level label is genuinely needed, derive it from the request that produced the metric you are attributing rather than from a majority vote across the page.
Related
- Up: Measuring Protocol Performance in the Field — the parent topic: sampling design, the
Alt-Svcholdback and the analysis that turns these rows into a verdict - Reading Server-Timing Headers for Protocol Stalls — the server-side half of the same entry, and how to split the waiting phase it exposes
- Network Waterfall Anatomy & Timing Metrics — how each Resource Timing field maps onto a row in the Network panel