Your resource-timing collection shows vendor scripts with transferSize: 0 and every phase timing at zero, so the per-vendor cost table you built reports the heaviest third parties as free.

Triage: Separate Cache Hits From Opaque Entries

A zero transferSize has two meanings and conflating them understates your third-party weight substantially.

  1. In the console, run performance.getEntriesByType('resource') and inspect a vendor entry.
  2. Check transferSize, encodedBodySize and decodedBodySize together. All three zero indicates an opaque cross-origin entry; a zero transfer with non-zero decoded size is a cache hit.
  3. Check nextHopProtocol. An empty string is another marker of an opaque entry.
  4. Confirm from the Network panel that the resource genuinely transferred bytes on this load.
Three zeroes mean different things Three interpretations of a zero transferSize. All size fields zero and an empty protocol indicates an opaque cross-origin entry with no timing permission. A zero transfer with a non-zero decoded size indicates a cache hit, where the resource was genuinely free on this load. And a genuinely zero-byte response is rare but possible. Only the first is a measurement gap. all sizes zero, no protocol opaque cross-origin entry a measurement gap the vendor withheld the header transfer 0, decoded > 0 a cache hit genuinely free this load exclude, do not count as free all genuinely zero an empty response rare usually a beacon Reporting the first as free is the error. It is not free; it is unmeasured.

Root Cause: Timing Data Is Opt-In Across Origins

Resource timing exposes detailed phase information — DNS, connection, TLS, request, response — and sizes for every resource a page loads. For same-origin resources that is unconditional. For cross-origin resources it would leak information about the visitor’s relationship with another site: cache state, response size, and whether they had visited before.

The platform therefore makes it opt-in. A cross-origin response that carries Timing-Allow-Origin naming your origin (or *) exposes its full timing; one that does not exposes only startTime, duration and responseEnd, with every size and phase field forced to zero. The entry still exists — you know the request happened and roughly how long it took — but you cannot say what it cost.

This is why per-vendor byte accounting so often understates. The vendors most worth measuring are frequently the ones that omit the header, either deliberately or because nobody asked, so the table that ranks your third parties by weight systematically ranks the opaque ones last.

What the header unlocks A comparison of what resource timing exposes with and without the Timing-Allow-Origin header. Without it, only start time, duration and response end are available, and every size and phase field reads zero. With it, transfer size, encoded and decoded body sizes, and the full DNS, connection, TLS and request phases become readable, which is what makes per-vendor cost accounting possible. without the header startTime, duration responseEnd every size field: 0 every phase: 0 with the header transferSize, encodedBodySize decodedBodySize DNS, connect, TLS phases per-vendor cost becomes real The entry exists either way. Only the header decides whether it carries numbers.

Resolution: Ask, Configure, and Estimate the Rest

For resources you control — a self-hosted vendor bundle, a proxied script, your own CDN — set the header:

Timing-Allow-Origin: https://www.example.com

For third-party origins, this is a request to the vendor rather than a configuration change, and it is a reasonable one: the header exposes timing for their own resource to a site that already loads it. Many vendors set it on request and some set it by default; asking costs an email and occasionally works.

For the vendors that never will, measure differently rather than reporting zero:

// Fall back to a synthetic measurement for opaque origins, and mark it as such.
function costFor(entry, syntheticSizes) {
  const opaque = entry.transferSize === 0 && entry.decodedBodySize === 0
                 && entry.nextHopProtocol === '';
  if (!opaque) return { bytes: entry.transferSize, source: 'field' };

  const host = new URL(entry.name).host;
  return {
    // A size measured once in a synthetic run, refreshed periodically.
    bytes: syntheticSizes[host] ?? null,
    source: syntheticSizes[host] ? 'synthetic' : 'unknown',
  };
}

Carrying the source field through to the dashboard is the part that matters. A vendor cost table with a mix of measured and estimated figures is honest and usable; one that silently substitutes estimates is neither, and one that reports zero is actively misleading.

Verification: The Opaque Count Falls

After a vendor adds the header, confirm the entry now reports non-zero sizes and phases in a fresh session. Track the share of third-party bytes that are measured rather than estimated as a metric in its own right — it starts low on most sites and rising it is a concrete, reportable improvement.

Coverage as a metric A stacked bar showing the share of third-party bytes that are directly measured, estimated from synthetic runs, and entirely unknown. The goal is to grow the measured segment by asking vendors for the header, shrink the unknown segment by adding synthetic measurement, and report the composition rather than presenting the total as if it were all measured. measured header present estimated synthetic runs unknown no data at all Report the composition. A single total implies a confidence the data does not support. Track the measured share over time — it is the only number that improves when a vendor says yes.

Why Vendors Withhold It

Understanding the reasons makes the request more likely to succeed. Some omit the header simply because nobody configured it — the default in most CDN and server software is absent, and no one has raised it. That case is the easy one, and a specific request naming the header usually resolves it.

Others withhold deliberately, on the reasoning that response sizes and cache state reveal something about their infrastructure or their other customers. That argument is weaker than it sounds for a script you already load and could measure with a proxy, but it is the position, and it tends to be held by larger vendors with formal security review processes.

A third group is unaware the header exists at all, which is worth approaching as a documentation request rather than a security one. Framing it as “we cannot include your resource in our performance budgets without this” is more effective than framing it as a missing header, because the first describes a consequence they care about — a customer unable to justify their cost — and the second describes a configuration they have no reason to change.

The Same Header Governs More Than Sizes

It is worth knowing what else becomes available, because the argument for asking gets stronger. Beyond sizes, the header exposes the full phase breakdown — DNS lookup, connection, TLS negotiation, request and response — which is what lets you distinguish a vendor that is slow because their server is slow from one that is slow because your page never warmed a connection to them.

Without it those two look identical: a long duration and nothing else. With it, a large connectEnd - connectStart points at a missing preconnect on your side, while a large responseStart - requestStart points at the vendor’s own latency. The first is yours to fix and the second is theirs, and the header is what tells you which conversation to have.

That distinction also changes what you can reasonably ask a vendor for. Reporting “your script takes 400 ms” invites a shrug; reporting “your script spends 280 ms in time-to-first-byte from your London edge” is a specific, checkable claim about their infrastructure — and one you cannot make at all for an opaque resource.

Common Pitfalls

  • Treating zero as free. The single most consequential error, because it makes the heaviest vendors invisible in exactly the table built to rank them.
  • Setting the header to * on your own origins carelessly. It exposes timing to every site that embeds your resources. Name origins where you can.
  • Assuming a proxied vendor is covered. Proxying makes the resource same-origin, which does expose timing — and also credits the cost to first-party code unless your attribution matches on path.
  • Not distinguishing estimated from measured. A dashboard mixing the two without a marker will be quoted as if it were all measured.

Frequently Asked Questions

Does the header have any security cost for us?

On your own resources it exposes timing and size information to whichever origins you name, which for a public static asset is close to no information at all — anyone can fetch it directly and measure it. The consideration is different for a resource whose size or cache state varies per visitor, where timing can become a side channel.

Name specific origins rather than using * where you can, and be more careful about authenticated or personalised responses than about static bundles. For the common case of a JavaScript file served identically to everyone, the exposure is negligible and the measurement benefit is real.

Can we measure opaque resources some other way?

Synthetically, yes. A headless run that loads the page and reads the network log gets full sizes for every resource regardless of headers, because it observes the transfer directly rather than through the page’s own timing API. That gives you a per-vendor size you can apply to field entries as an estimate.

What it cannot give you is the field distribution — how the size varies by cohort, region or experiment — which is exactly what the header would provide. Treat the synthetic figure as a constant applied to a variable reality, refresh it on a schedule, and keep it labelled as an estimate wherever it appears.

Is this related to the CORS `crossorigin` attribute?

They are separate mechanisms that are easily confused. The crossorigin attribute controls how the request is made and is what makes subresource integrity possible; Timing-Allow-Origin is a response header that controls what the timing API exposes. A script can be fetched in CORS mode and still report opaque timings if the vendor omits the header.

You need both for the full picture: CORS mode to verify integrity, and the timing header to measure cost. Asking a vendor for both at once is a reasonable single request, and the two together are what let a third-party script be both pinned and budgeted.

Should the job block deploys, or only report?

Report. A vendor change is not caused by the deploy in front of you, and blocking an unrelated release on it produces exactly the pressure that gets checks disabled. The change has already reached production regardless of whether you ship anything today.

Where blocking is appropriate is narrower: a deploy that would ship an updated digest should not proceed if the digest no longer matches what is live, because that combination means someone recorded a value that is already stale. That is a check on the pull request rather than on the schedule, and it is cheap to add alongside.


Up: Instrumenting Third-Party Scripts with PerformanceObserver