# Third-party impact by domain

> Source: <https://webperf-snippets.net/Loading/Third-Party-Impact-by-Domain>

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

```js
// 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.

> **Note:** 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.

> **Warning:** 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`.

> **Warning:** 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:

Interactive demo: [Interactive ranking of six invented third-party domains ordered as the snippet does, with render-blocking first, then main-thread time, then transfer size, where deferring, delaying until interaction or removing a domain changes the order, the totals and the issues reported](https://webperf-snippets.net/demos/third-party-ranking.html). Choose an action for each domain, or press the button to fix the top one, and see the ranking update.

### 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](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/transferSize)       |  ✅ 54  | ✅ 17 |   ✅ 45  | ✅ 16.4 |
| [PerformanceResourceTiming.encodedBodySize](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/encodedBodySize) |  ✅ 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](https://developer.mozilla.org/docs/Web/API/PerformanceLongAnimationFrameTiming)                       |  ✅ 123 | ✅ 123 |    ❌    |    ❌   |
| [PerformanceResourceTiming.renderBlockingStatus](https://developer.mozilla.org/docs/Web/API/PerformanceResourceTiming/renderBlockingStatus) |  ✅ 107 | ✅ 107 |    ❌    |    ❌   |

Source: [MDN browser compatibility data](https://github.com/mdn/browser-compat-data), version 8.1.4.

### Further reading

- [Loading third-party JavaScript](https://web.dev/articles/loading-third-party-javascript) | web.dev
- [Efficiently load third-party JavaScript](https://developer.chrome.com/docs/lighthouse/performance/third-party-facades) | Chrome Developers
- [Long Animation Frames API](https://developer.chrome.com/docs/web-platform/long-animation-frames) | Chrome Developers
- [First and Third Party Script Info](https://webperf-snippets.net/Loading/First-And-Third-Party-Script-Info.md) | Lists the scripts behind each party
- [Long Animation Frames Script Attribution](https://webperf-snippets.net/Interaction/Long-Animation-Frames-Script-Attribution.md) | Attributes long frames to scripts
