Your performance dashboard says a third-party script is slow. Then every phase in its PerformanceResourceTiming entry is zero: DNS, connection, request, response, even sizes. That is not a broken browser and it is not a CORS failure. It is the Resource Timing privacy model working as designed.
This matters because third-party JavaScript, fonts, images, analytics, and CDNs are usually where a useful performance investigation starts. The tempting response is to treat performance.getEntriesByType("resource") as a packet trace. It is not. It is a browser API with deliberately limited cross-origin disclosure.
This article builds a small, reviewable resource-cost reporter, explains the zeroes, and shows the narrow server-side change that enables detailed timing when you actually control the resource.
What Resource Timing records
The Resource Timing specification defines entries for HTTP(S) resources fetched by a document. Entries can cover markup-driven loads such as scripts, stylesheets, images, iframes, audio, and video, as well as fetch() and XHR. Cached resources and attempted network fetches can also produce entries, so an entry is evidence that the browser processed a fetch—not proof of a fresh transfer.
Each PerformanceResourceTiming entry has a URL, an initiatorType, a duration, and fields such as transferSize, encodedBodySize, requestStart, and responseEnd. A useful first pass is to group entries by origin and initiator rather than immediately ranking individual URLs.
function resourceSummary(entries = performance.getEntriesByType("resource")) {
const groups = new Map();
for (const entry of entries) {
const url = new URL(entry.name);
const key = `${url.origin} | ${entry.initiatorType}`;
const current = groups.get(key) ?? {
origin: url.origin,
initiator: entry.initiatorType,
requests: 0,
durationMs: 0,
transferredBytes: 0,
opaque: 0,
};
current.requests += 1;
current.durationMs += entry.duration;
current.transferredBytes += entry.transferSize;
if (entry.duration > 0 && entry.transferSize === 0 &&
entry.requestStart === 0 && entry.responseStart === 0) {
current.opaque += 1;
}
groups.set(key, current);
}
return [...groups.values()]
.map(group => ({
...group,
durationMs: Math.round(group.durationMs),
transferredKiB: Math.round(group.transferredBytes / 1024),
}))
.sort((a, b) => b.durationMs - a.durationMs);
}
console.table(resourceSummary());
This is an investigation aid, not a universal accounting system. A resource can be served from cache; one network fetch can satisfy more than one consumer; an iframe owns timing for its own subresources; and summing durations double-counts concurrent work. Treat the table as a way to decide what to inspect next.
Why cross-origin details disappear
The browser may expose an entry for a cross-origin resource while masking its detailed timestamps and sizes. The relevant gate is not the request's CORS mode. It is the resource server's Timing-Allow-Origin response header.
For example, a document at https://app.example can load:
<script src="https://static.vendor.example/widget.js"></script>
The browser can still tell the page that the resource existed and how long the overall operation took. But revealing DNS, connection, request, response, and size information could expose properties of another origin's infrastructure or a user's network path. The Resource Timing privacy considerations explicitly address this cross-origin information boundary.
That is why a client-side change such as adding crossorigin to the script tag does not grant timing visibility. crossorigin controls how the element fetches and whether a response can be used under CORS rules; it does not opt the server into detailed Resource Timing.
The server opt-in: Timing-Allow-Origin
If you operate the resource origin and want a specific site to receive detailed timings, return:
Timing-Allow-Origin: https://app.example
For a resource intentionally measurable from any public origin, the header can be:
Timing-Allow-Origin: *
The header is documented in MDN's Timing-Allow-Origin reference, and its semantics are defined by the Resource Timing specification. It is independent from Access-Control-Allow-Origin; deployment commonly needs both headers only when the browser also needs to read the response body via CORS.
For an Nginx-served static asset, a deliberately narrow configuration might look like:
location /assets/ {
add_header Timing-Allow-Origin "https://app.example" always;
}
Use the narrowest origin list that supports your diagnostic goal. * is convenient, but it makes detailed timing available to every embedding site. Timing alone is less sensitive than response content, yet it can still reveal operational characteristics. Do not add it by habit to a vendor integration you do not control.
Observe future entries instead of taking one snapshot
A single call to getEntriesByType() misses resources loaded later by client-side navigation, lazy loading, or user interaction. A PerformanceObserver makes the reporter useful in a real application:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType !== "resource") continue;
const url = new URL(entry.name);
if (url.origin === location.origin) {
console.debug("first-party resource", {
type: entry.initiatorType,
duration: Math.round(entry.duration),
transferSize: entry.transferSize,
});
}
}
});
observer.observe({ type: "resource", buffered: true });
The buffered: true option includes entries that were already recorded before the observer started. Keep the callback cheap: queue summaries and ship them at controlled points, rather than posting every resource entry from a hot path.
There is also a finite resource-timing buffer. Long-lived single-page apps should listen for resourcetimingbufferfull, process the entries they need, and call performance.clearResourceTimings(). Clearing is local to the page's timeline; it does not clear browser history, caches, or network logs.
Design a performance budget that respects the boundary
Start with signals you can defend:
- Count third-party origins and requests.
- Track elapsed duration by origin, while remembering concurrency makes totals non-additive.
- Track transferred bytes only for resources whose origin has granted timing access.
- Keep raw resource URLs out of routine analytics when query strings may contain identifiers or tokens.
- Agree with vendors on a temporary, narrow
Timing-Allow-Originrollout when phase-level diagnosis is necessary.
The last point is usually the fastest route to an answer. Do not try to infer hidden connection phases, and do not treat zero byte fields as a network failure. Ask the resource owner whether it is appropriate to expose timing to your application origin, then verify the response header in a normal browser request.
A boundary, not a blind spot
Resource Timing is powerful precisely because it is constrained. It can show that a third-party origin is involved in a slow experience without automatically turning a visitor's browser into a cross-origin network probe.
Use the API to identify ownership and high-level cost. Use Timing-Allow-Origin only where there is an explicit operational reason and the resource owner agrees. That produces better measurements—and a performance practice that does not quietly trade away privacy for a few extra timestamps.
Top comments (0)