Third-party impact by domain
Chrome 54+ Edge 17+ Firefox 45+ Safari 16.4+ Details
Groups every third-party request by root domain and reports, per domain, the request count, the transfer size, whether any of its requests blocks rendering, and the main-thread time its scripts take inside long animation frames. It answers which third party to fix first.
Why this matters
A page rarely suffers from third parties as a group. A few domains usually cause most of the damage: one tag manager that blocks rendering, one widget that runs 300 ms of script. Ranking domains by impact turns a long list of requests into a short list of decisions: remove, defer, self-host or replace.
Snippet
Run it from an AI agent: the prompt uses the CLI to measure any URL and report the result.
// Third-Party Impact by Domain
// Groups third-party requests by root domain and reports requests, transfer size, render-blocking and main-thread time
// https://webperf-snippets.net
(() => {
// @shared isOwnRequest
// True for the Resource Timing entry of a request a snippet made with ownRequestUrl: the page
// did not make it, so no snippet counts it.
function isOwnRequest(entry) {
return entry.name.endsWith("#webperf-snippets");
}
// @end-shared isOwnRequest
const MAX_ITEMS = 50;
const LOAF_WARNING_MS = 250; // Script time in long animation frames above which one domain is a problem
const MAX_LISTED_DOMAINS = 5;
// Domains that belong to the site but differ from the page's root domain, such as its own CDN.
// They count as first party. Example: const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];
const OWN_DOMAINS = [];
// @shared formatBytes
function formatBytes(bytes) {
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
}
// @end-shared formatBytes
// A size that misses some resources is a lower bound, and one that misses all of them is unknown
const sizeLabel = (bytes, unknown, total) => {
if (total > 0 && unknown === total) return "unknown";
return (unknown > 0 ? "≥ " : "") + formatBytes(bytes);
};
// @shared getRootDomain
function getRootDomain(hostname) {
const host = hostname.replace(/\.$/, "");
// An IP address has no registrable domain, so each address is its own root
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return host;
const parts = host.split(".");
if (parts.length <= 2) return host;
// Country-code domains with a second-level suffix: example.co.uk, example.com.au, example.ac.jp
const secondLevelSuffixes = ["ac", "co", "com", "edu", "go", "gob", "gouv", "gov", "govt", "mil", "ne", "net", "nom", "or", "org", "sch"];
const tld = parts[parts.length - 1];
const sld = parts[parts.length - 2];
if (tld.length === 2 && secondLevelSuffixes.includes(sld)) return parts.slice(-3).join(".");
return parts.slice(-2).join(".");
}
// @end-shared getRootDomain
// @shared isFirstParty
function isFirstParty(hostname) {
const root = getRootDomain(hostname);
if (root === getRootDomain(location.hostname)) return true;
return OWN_DOMAINS.some((d) => getRootDomain(String(d).trim().toLowerCase().replace(/^[a-z]+:\/\//, "").split("/")[0]) === root);
}
// @end-shared isFirstParty
// @shared logOwnDomainsHint
function logOwnDomainsHint(thirdPartyCount) {
if (OWN_DOMAINS.length === 0 && thirdPartyCount > 0) {
console.log(
'%cℹ️ OWN_DOMAINS is empty. If this site serves its own assets from other domains (for example a CDN), add them at the top of the snippet, such as const OWN_DOMAINS = ["cdn.example.net"];, and run it again so they count as first party.',
"color: #3b82f6;"
);
}
}
// @end-shared logOwnDomainsHint
// Root domain of an http(s) URL, or null for anything else (data:, blob:, empty)
const rootDomainOf = (url) => {
try {
const u = new URL(url);
return /^https?:$/.test(u.protocol) ? { root: getRootDomain(u.hostname), first: isFirstParty(u.hostname) } : null;
} catch {
return null;
}
};
const domains = new Map();
const domainEntry = (root) => {
if (!domains.has(root)) {
domains.set(root, { domain: root, requests: 0, transferBytes: 0, sizeUnknownCount: 0, renderBlocking: false, loafMs: null, loafScripts: 0 });
}
return domains.get(root);
};
let firstPartyRequests = 0;
let thirdPartyRequests = 0;
let thirdPartyBytes = 0;
let sizeUnknownCount = 0;
performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e)).forEach((entry) => {
const parsed = rootDomainOf(entry.name);
if (!parsed) return;
if (parsed.first) {
firstPartyRequests++;
return;
}
const d = domainEntry(parsed.root);
d.requests++;
thirdPartyRequests++;
// Cross-origin resources without Timing-Allow-Origin report every size as zero
const sizeKnown = entry.transferSize > 0 || entry.encodedBodySize > 0 || entry.decodedBodySize > 0;
if (sizeKnown) {
const bytes = entry.transferSize || entry.encodedBodySize || 0;
d.transferBytes += bytes;
thirdPartyBytes += bytes;
} else {
d.sizeUnknownCount++;
sizeUnknownCount++;
}
if (entry.renderBlockingStatus === "blocking" && (entry.initiatorType === "script" || entry.initiatorType === "link")) {
d.renderBlocking = true;
}
});
// Main-thread time: script time inside long animation frames, attributed by the script's source URL
const loafSupported = !!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame");
let thirdPartyLoafMs = null;
let longFrames = null;
let longFramesMs = null;
if (loafSupported) {
thirdPartyLoafMs = 0;
domains.forEach((d) => {
d.loafMs = 0;
});
const frames = performance.getEntriesByType("long-animation-frame");
longFrames = frames.length;
longFramesMs = frames.reduce((total, frame) => total + frame.duration, 0);
frames.forEach((frame) => {
(frame.scripts || []).forEach((script) => {
const parsed = rootDomainOf(script.sourceURL);
if (!parsed || parsed.first) return;
const d = domainEntry(parsed.root);
d.loafMs = (d.loafMs || 0) + script.duration;
d.loafScripts++;
thirdPartyLoafMs += script.duration;
});
});
domains.forEach((d) => {
d.loafMs = Math.round(d.loafMs || 0);
});
thirdPartyLoafMs = Math.round(thirdPartyLoafMs);
longFramesMs = Math.round(longFramesMs);
}
// Time in long frames that no third-party script accounts for: first-party scripts, scripts under
// the 5 ms that Long Animation Frames list, and style, layout and rendering work
const unattributedLoafMs = loafSupported ? Math.max(0, longFramesMs - thirdPartyLoafMs) : null;
const loafUnattributed = loafSupported && domains.size > 0 && thirdPartyLoafMs === 0 && longFrames > 0;
// Render-blocking first, then main-thread time, then transfer size
const all = [...domains.values()].sort(
(a, b) =>
Number(b.renderBlocking) - Number(a.renderBlocking) ||
(b.loafMs || 0) - (a.loafMs || 0) ||
b.transferBytes - a.transferBytes ||
a.domain.localeCompare(b.domain)
);
const blocking = all.filter((d) => d.renderBlocking);
const heavy = all.filter((d) => (d.loafMs || 0) > LOAF_WARNING_MS);
const corsLimitedAnalysis = sizeUnknownCount > 0;
const listDomains = (list) => {
const names = list.slice(0, MAX_LISTED_DOMAINS).map((d) => d.domain);
return names.join(", ") + (list.length > MAX_LISTED_DOMAINS ? ` and ${list.length - MAX_LISTED_DOMAINS} more` : "");
};
const issues = [];
if (blocking.length > 0) {
issues.push({
severity: "warning",
message: `${blocking.length} third-party domain(s) serve render-blocking resources (${listDomains(blocking)}); load them async or defer, or self-host them`,
});
}
heavy.slice(0, MAX_LISTED_DOMAINS).forEach((d) => {
issues.push({
severity: "warning",
message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
});
});
if (loafUnattributed) {
issues.push({
severity: "info",
message: `${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
});
}
if (corsLimitedAnalysis) {
issues.push({
severity: "info",
message: `${sizeUnknownCount} third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound`,
});
}
console.group("%c🌐 Third-Party Impact by Domain", "font-weight: bold; font-size: 14px;");
console.log(
`Third-party domains: ${all.length} | requests: ${thirdPartyRequests} | transfer: ${sizeLabel(thirdPartyBytes, sizeUnknownCount, thirdPartyRequests)} | first-party requests: ${firstPartyRequests}`
);
if (all.length > 0) {
console.table(
all.slice(0, MAX_ITEMS).map((d) => ({
Domain: d.domain,
Requests: d.requests,
Size: sizeLabel(d.transferBytes, d.sizeUnknownCount, d.requests),
"Render-blocking": d.renderBlocking ? "yes" : "no",
"Main thread": d.loafMs === null ? "n/a" : d.loafMs + " ms",
}))
);
} else {
console.log("%c✅ No third-party requests found", "color: #22c55e;");
}
if (!loafSupported) {
console.log("%cℹ️ Long Animation Frames are not supported; main-thread time is unavailable (Chrome 123+)", "color: #3b82f6;");
}
if (loafUnattributed) {
console.log(
`%cℹ️ ${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
"color: #3b82f6;"
);
}
if (corsLimitedAnalysis) {
console.log("%cℹ️ Some third-party sizes are hidden (no Timing-Allow-Origin); sizes are a lower bound", "color: #3b82f6;");
}
logOwnDomainsHint(all.length);
console.groupEnd();
return {
script: "Third-Party-Impact-by-Domain",
status: "ok",
count: all.length,
corsLimitedAnalysis,
details: {
thirdPartyRequests,
thirdPartyBytes,
thirdPartyLoafMs,
longFrames,
unattributedLoafMs,
firstPartyRequests,
blockingDomains: blocking.length,
loafSupported,
sizeUnknownCount,
},
items: all.slice(0, MAX_ITEMS),
issues,
};
})();
Understanding the results
A request is third party when its root domain differs from the root domain of location.hostname. Subdomains of the page’s own site, such as cdn.example.com on www.example.com, are first party.
A site’s own CDN on a different root domain, such as bbci.co.uk for bbc.com, is reported as a third party, because the comparison uses root domains only. To count those domains as first party, list them in the OWN_DOMAINS constant at the top of the snippet, for example const OWN_DOMAINS = ["bbci.co.uk", "bbc.co.uk"];. Entries can be domains or URLs. While the list is empty, the snippet prints a reminder in the console.
Each entry in items describes one domain:
| Field | Description |
|---|---|
domain | Root domain of the third party |
requests | Requests to the domain |
transferBytes | Transfer size of the requests whose size is readable (encodedBodySize when transferSize is 0) |
sizeUnknownCount | Requests whose size is hidden |
renderBlocking | true when a script or stylesheet from the domain has renderBlockingStatus of blocking |
loafMs | Script time, in ms, attributed to the domain in long animation frames, or null when the browser does not support them |
loafScripts | Script executions attributed to the domain in long animation frames |
items holds at most 50 domains, sorted by impact: render-blocking domains first, then the most main-thread time, then the largest transfer. count and details cover every third-party domain.
| Field | Description |
|---|---|
count | Number of third-party domains |
details.thirdPartyRequests | Third-party requests |
details.thirdPartyBytes | Transfer size of the third-party requests with a readable size |
details.thirdPartyLoafMs | Third-party script time in long animation frames |
details.longFrames | Long animation frames on the page, or null when the browser does not support them |
details.unattributedLoafMs | Time in long animation frames that no third-party script accounts for, or null when unsupported |
details.firstPartyRequests | First-party resource requests, for comparison |
details.blockingDomains | Domains with a render-blocking request |
details.loafSupported | false when the browser has no Long Animation Frames API |
details.sizeUnknownCount | Third-party requests whose size is hidden |
Issues
| Severity | When |
|---|---|
warning | One or more third-party domains serve render-blocking resources |
warning | A domain’s scripts take more than 250 ms of main-thread time in long animation frames |
info | Long animation frames exist but no third-party script was attributed in them |
info | Some sizes are hidden because of a missing Timing-Allow-Origin header |
Main-thread time
The snippet reads long-animation-frame entries and adds up the duration of every entry in scripts[], grouped by the root domain of sourceURL. Only frames longer than 50 ms are recorded, and only script executions of 5 ms or more appear in scripts[], so loafMs is a lower bound of the real script cost. Inline scripts and scripts with an empty sourceURL count as first party and are left out.
Long Animation Frames need Chrome 123 or later. Elsewhere details.loafSupported is false and loafMs is null; the rest of the report still works.
When the page has third-party domains and long animation frames, but no third-party script was attributed in any of them, every Main thread value is 0 ms and the snippet adds an info note with the number of frames and their total time. The 0 ms means that no third-party script ran for 5 ms or more inside those frames; it does not mean that third parties cost nothing. The frames can come from first-party code, from third-party scripts shorter than the threshold, or from style, layout and rendering work, which is the time details.unattributedLoafMs reports.
The buffer holds the frames since the page started, so run the snippet after the page settled or after the interaction you want to measure.
Cross-origin resources without Timing-Allow-Origin
Browsers report every size as zero for a cross-origin resource unless its server sends a Timing-Allow-Origin header. The snippet counts those requests in sizeUnknownCount (per domain and in details), keeps them out of the byte totals, and sets corsLimitedAnalysis to true.
When corsLimitedAnalysis is true, treat transferBytes and details.thirdPartyBytes as lower bounds. Request counts, render-blocking flags and main-thread time are not affected.
Fix the top domain first
Apply a fix to a domain, or let the demo apply the suggested one to the top of the ranking, and watch how the order, the totals and the findings of the snippet change:
Acting on the results
| Finding | Options |
|---|---|
| Render-blocking domain | Add async or defer, load it after the first paint, or self-host the file |
High loafMs | Delay the script until interaction or idle time, use a facade, or remove it |
| Many requests, low size | Consolidate tags or remove unused ones |
Large transferBytes | Lazy-load the widget or replace it with a lighter alternative |
Browser support
The snippet needs these features to run.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| PerformanceResourceTiming.transferSize (opens in a new tab) | 54 | 17 | 45 | 16.4 |
| PerformanceResourceTiming.encodedBodySize (opens in a new tab) | 54 | 17 | 45 | 16.4 |
| All of the above | 54 | 17 | 45 | 16.4 |
The snippet reports on these features. It runs without them, and its advice on each one applies where it is supported.
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| PerformanceLongAnimationFrameTiming (opens in a new tab) | 123 | 123 | ||
| PerformanceResourceTiming.renderBlockingStatus (opens in a new tab) | 107 | 107 |
Source: MDN browser compatibility data (opens in a new tab), version 8.1.4.
Further reading
- Loading third-party JavaScript (opens in a new tab) | web.dev
- Efficiently load third-party JavaScript (opens in a new tab) | Chrome Developers
- Long Animation Frames API (opens in a new tab) | Chrome Developers
- First and Third Party Script Info | Lists the scripts behind each party
- Long Animation Frames Script Attribution | Attributes long frames to scripts