Skip to Content
LoadingThird-Party Impact by Domain

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:

FieldDescription
domainRoot domain of the third party
requestsRequests to the domain
transferBytesTransfer size of the requests whose size is readable (encodedBodySize when transferSize is 0)
sizeUnknownCountRequests whose size is hidden
renderBlockingtrue when a script or stylesheet from the domain has renderBlockingStatus of blocking
loafMsScript time, in ms, attributed to the domain in long animation frames, or null when the browser does not support them
loafScriptsScript 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.

FieldDescription
countNumber of third-party domains
details.thirdPartyRequestsThird-party requests
details.thirdPartyBytesTransfer size of the third-party requests with a readable size
details.thirdPartyLoafMsThird-party script time in long animation frames
details.longFramesLong animation frames on the page, or null when the browser does not support them
details.unattributedLoafMsTime in long animation frames that no third-party script accounts for, or null when unsupported
details.firstPartyRequestsFirst-party resource requests, for comparison
details.blockingDomainsDomains with a render-blocking request
details.loafSupportedfalse when the browser has no Long Animation Frames API
details.sizeUnknownCountThird-party requests whose size is hidden

Issues

SeverityWhen
warningOne or more third-party domains serve render-blocking resources
warningA domain’s scripts take more than 250 ms of main-thread time in long animation frames
infoLong animation frames exist but no third-party script was attributed in them
infoSome 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:

Choose an action for each domain, or press the button to fix the top one, and see the ranking update. Open demo in a new tab ↗

Acting on the results

FindingOptions
Render-blocking domainAdd async or defer, load it after the first paint, or self-host the file
High loafMsDelay the script until interaction or idle time, use a facade, or remove it
Many requests, low sizeConsolidate tags or remove unused ones
Large transferBytesLazy-load the widget or replace it with a lighter alternative

Browser support

The snippet needs these features to run.

FeatureChromeEdgeFirefoxSafari
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.

FeatureChromeEdgeFirefoxSafari
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

Last updated on
MIT 2026 © Joan León | @nucliweb