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.

What has already happened by the time a deferred collector attaches A five-lane timeline over three seconds for one gallery navigation. The document finishes at 420 milliseconds over h2, the stylesheet at 690 and the LCP image at 1180 over h3, and 216 gallery images stream from 700 to 2600 milliseconds. The collector bundle only attaches at 2700 milliseconds, marked by a vertical line, so a non-buffered observer records 7 of the 221 entries and misses the stylesheet and the LCP image entirely. One gallery navigation: 221 resource entries, one late collector collector attaches — 2 700 ms document · h2 420 ms app.css · h3 hero.avif · h3 LCP 1 180 216 images · h3 216 gallery images, reused connection rum.js (defer) 0 0.5 s 1.0 s 1.5 s 2.0 s 2.5 s 3.0 s Non-buffered observer: 214 of the 221 entries were created before it existed, so it records 7. With buffered: true the same collector replays the entry buffer and records all 221. The stylesheet and the LCP image — the two rows that decide the answer — are in the missing 214.

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.

Classifying one nextHopProtocol value before it reaches a bucket A two-level decision tree. A set value of h3, h2 or http slash 1.1 is a real negotiated protocol. An empty string branches three ways: a non-zero workerStart means the service worker built the response, a zero transferSize with a non-zero decodedBodySize means a memory cache hit, and anything else means the asset host omitted Timing-Allow-Origin. Classifying one nextHopProtocol value before it reaches a bucket entry.nextHopProtocol one field, four meanings value set value empty h3 / h2 / http/1.1 a real negotiated protocol "" empty string not a protocol — a gap workerStart > 0 the service worker built the response body transferSize 0 decodedBodySize > 0 memory cache hit neither of those the host omits Timing-Allow-Origin Only the left branch is a protocol observation. The three on the right are collection defects wearing the same empty value — bucket them apart.

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 observe navigation too. The flag replays the entry buffer into your callback, which is the only way a late collector sees the stylesheet and the LCP image. Observe navigation in 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 register resourcetimingbufferfull — not to react, but to stamp a truncated: true flag 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, then deliveryType === 'cache' (with the transferSize === 0 && decodedBodySize > 0 fallback for WebKit), then treat the remainder as timing-allow-gated. Four buckets, not one.
  • [ ] 4. Close the Timing-Allow-Origin gap the audit exposes. Every host in the gated bucket needs the header; the ones you cannot control stay in an explicit gated bucket 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 h2 for the document and h3 for 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 on visibilitychange. Take takeRecords() before sending so entries queued since the last callback are not stranded in the observer, and use sendBeacon so 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.
Entry buffer occupancy across a five-view single-page-app session A rising step line of cumulative resource timing entries: 221 after the initial load, then 351, 493, 611, 690 and 748 after five client-side route changes. A horizontal line marks the default ceiling of 250 entries, which the session crosses during the first route change, so 498 of the 748 entries are dropped before any observer or replay can see them. Entry buffer occupancy across a five-view session, no clearResourceTimings() entries created ceiling 0 200 400 600 800 default ceiling — 250 entries load route 1 route 2 route 3 route 4 route 5 Cumulative entries created by the session: 748 498 of them are dropped before an observer or a replay can see them Raising the ceiling with setResourceTimingBufferSize(600) is only half the fix. The other half is clearResourceTimings() after every flush, so a long session never reaches it.

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
Measured protocol mix before and after fixing the collector Two stacked bars for the same gallery navigation. Before the fix the mix reads 38 percent unknown, 44 percent h2 and 18 percent h3. After the fix, with a buffered observer, a raised buffer ceiling and the empty string classified, it reads 2 percent unknown, 21 percent h2 and 77 percent h3 — with no change to any CDN setting. Measured protocol mix, same page and same CDN configuration unknown or gated h2 h3 Before — 7 rows collected, 250 in the buffer unknown 38% h2 44% h3 18% After — 221 rows collected, buffer never full h2 21% h3 77% Of the 84 unknown rows: 61 were timing-allow-gated, 18 were memory-cache hits, 5 came from the service worker. None of them was http/1.1. No CDN setting changed between the two bars — only the collector and the bucketing did.

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