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.
- In the console, run
performance.getEntriesByType('resource')and inspect a vendor entry. - Check
transferSize,encodedBodySizeanddecodedBodySizetogether. All three zero indicates an opaque cross-origin entry; a zero transfer with non-zero decoded size is a cache hit. - Check
nextHopProtocol. An empty string is another marker of an opaque entry. - Confirm from the Network panel that the resource genuinely transferred bytes on this load.
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.
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.
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.
Related
- Instrumenting Third-Party Scripts with PerformanceObserver
- Attributing RUM Data to Third-Party Vendors
- Setting a Transfer Size Budget for Third-Party Scripts
Up: Instrumenting Third-Party Scripts with PerformanceObserver