Third-Party Script Constraints

Uncontrolled external scripts are the primary driver of main-thread contention, directly degrading Total Blocking Time and Largest Contentful Paint, and a single bloated tag can erase a quarter's worth of first-party optimization overnight. This is the third-party enforcement layer of the Defining Web Performance Budgets reference: it converts vendor payloads — tag managers, analytics, ad networks, chat widgets — from invisible liabilities into named, budgeted, gated dependencies that fail a build the moment they exceed their allocation.

The discipline rests on a single principle: every third-party origin gets an explicit byte ceiling and an explicit main-thread ceiling, both calibrated at P75 on the device class you actually ship to, and both enforced in CI. Vendors that cannot prove they fit the budget load behind a facade, behind consent, or not at all. This page is the authoritative spec for setting those ceilings, loading scripts safely, and wiring the gate so a regression is caught in the pull request that introduces it, not in a field report three weeks later.

Architecture Overview

A request that arrives from a third-party origin passes through three control points before it is allowed to execute on the main thread: a CSP allowlist that decides whether the origin may load at all, a byte budget that caps transfer size, and a main-thread budget that caps evaluation time. Anything that breaches a ceiling is quarantined — deferred behind a facade, gated behind consent, or rejected in CI. The diagram below shows the flow.

Third-party script budget and quarantine flow An external vendor request is checked against a CSP allowlist, then a per-vendor byte budget, then a main-thread evaluation budget. Requests that pass all three execute normally; requests that breach any ceiling are quarantined behind a facade, deferred, consent-gated, or rejected by the CI gate. Vendor request CSP script-src allowlist Byte budget transferSize ceiling Main-thread eval-ms ceiling execute on thread Quarantine facade / consent-gate / CI reject
Each vendor request clears a CSP allowlist, a byte ceiling, and a main-thread ceiling; passing scripts execute, while any breach is quarantined behind a facade, consent gate, or CI rejection.

Why Third-Party Cost Compounds

First-party JavaScript is expensive but honest: you can see it in your bundle, split it, and delete it. Third-party cost is worse because it hides and it multiplies. A tag manager is not one script — it is a loader that fetches a container, and the container fetches tags, and those tags fetch pixels, and several of those pixels inject further scripts. What looks like a 45 KB analytics snippet in the source becomes 380 KB of transfer and 700 ms of main-thread evaluation once the cascade resolves in the field. On a mid-range mobile device on Fast 3G at P75, that cascade routinely pushes LCP past 4.0 s even when the first-party critical path is already tuned to render under 1.8 s at the same percentile.

Three properties make third-party payload dangerous, and each one is why a static byte budget on its own is not enough. First, the cost is dynamic: a vendor can ship a heavier build on any Tuesday and your bundle never changes, so a byte gate that only inspects your own artifacts never sees it. Second, the cost lands on the main thread late, during the interaction window, so it degrades INP rather than LCP and slips past load-time-only budgets. Third, it is correlated — vendors preconnect and load in the same burst, so their contention compounds rather than interleaving neatly. The chart below plots measured per-vendor main-thread evaluation against its ceiling for a representative retail template.

Measured vendor main-thread cost against per-vendor ceilings Four vendors are plotted as bars showing measured main-thread evaluation milliseconds at P75, each with a horizontal budget-ceiling marker; the tag manager at 240 and chat widget at 310 exceed their ceilings, while analytics at 95 and the ad network at 220 sit within budget. over budget within budget budget ceiling 0 100 200 300 main-thread ms 240 tag mgr 95 analytics 310 chat 220 ad net
Measured P75 main-thread evaluation per vendor with each vendor's ceiling drawn as a horizontal marker; the tag manager and chat widget breach and must move behind consent and a facade respectively.

The chart makes the enforcement decision concrete: the chat widget at 310 ms against a 150 ms ceiling is not a candidate for micro-optimization — it must be removed from the initial load entirely and hydrated behind a facade on user intent. The tag manager at 240 ms against 200 ms moves behind consent, which both defers its cost and shrinks it, because unconsented sessions never pay the tag payload at all.

Prerequisites and Environment

Constraining third-party scripts requires a build that you control and a CI runner that can measure real network and CPU cost under emulation. The work assumes you have already established first-party budgets and a Lighthouse CI pipeline.

  • @lhci/cli version 0.13 or newer — supplies the resource-summary:third-party assertion family used to gate vendor bytes and counts.
  • A versioned budget manifestthird-party-budget.yml committed alongside application code so every change is reviewable in a pull request.
  • CSP header control — the ability to emit a Content-Security-Policy header from your edge, server, or meta tag, so the script-src allowlist is the single source of truth for which origins may load.
  • An emulation profile — measure against a mid-range mobile device on Fast 3G at P75, not a desktop on fiber; vendor cost is dominated by CPU evaluation, which the profile in Device and Network Emulation Weighting makes deterministic.

Map dynamic values through environment variables so nothing is hardcoded:

  • LHCI_GITHUB_APP_TOKEN — status-check token for the budget gate.
  • STAGING_BASE_URL — the preview origin Lighthouse collects against.

Configuration Reference

Two artifacts define the constraint layer: a per-vendor budget manifest and a deferred loader. The manifest is the authoritative ledger of which origins are allowed and what each may cost; the loader enforces async loading and timeouts at runtime. Both are annotated inline.

# third-party-budget.yml — per-vendor ceilings, calibrated at P75 mobile Fast 3G
defaults:
  loading: async            # never render-blocking; no document.write
  timeout_ms: 3000          # abort the fetch if the vendor stalls
vendors:
  - name: tag-manager
    origin: https://www.googletagmanager.com
    max_transfer_kb: 60     # gzipped container ceiling
    max_main_thread_ms: 200 # evaluation budget on the main thread
    strategy: consent-gated # load only after consent grant
  - name: analytics
    origin: https://www.google-analytics.com
    max_transfer_kb: 45
    max_main_thread_ms: 120
    strategy: async
  - name: chat-widget
    origin: https://widget.example-chat.com
    max_transfer_kb: 70
    max_main_thread_ms: 150
    strategy: facade        # load real widget only on user click
  - name: ad-network
    origin: https://ads.example-net.com
    max_transfer_kb: 90
    max_main_thread_ms: 250
    strategy: lazy          # defer until below the fold or idle
// load-third-party.js — async loader with a hard timeout and facade hook
function loadVendor({ src, timeoutMs = 3000 }) {
  return new Promise((resolve, reject) => {
    const s = document.createElement("script");
    s.src = src;
    s.async = true; // off the critical path; parser-inserted is forbidden
    const timer = setTimeout(() => {
      s.remove();
      reject(new Error(`third-party timeout: ${src}`));
    }, timeoutMs);
    s.onload = () => { clearTimeout(timer); resolve(); };
    s.onerror = () => { clearTimeout(timer); reject(new Error(`failed: ${src}`)); };
    document.head.appendChild(s);
  });
}

// Facade: render a lightweight placeholder, hydrate the real vendor on intent.
function mountFacade(el, src) {
  const activate = () => loadVendor({ src }).catch(console.warn);
  el.addEventListener("click", activate, { once: true });
  el.addEventListener("pointerenter", activate, { once: true });
}

The async attribute keeps the script off the critical path; the timeout caps the blast radius of a stalled vendor; the facade defers a heavy widget (chat, video, map) until the user signals intent, removing it entirely from the initial load budget.

Choosing a Loading Strategy

Not every vendor gets the same treatment. The strategy field in the manifest is chosen by walking a short decision tree: anything not needed for first render is deferred to idle; anything that legally requires consent loads only after the grant; heavy interactive widgets load behind a facade; and only the small, always-needed measurement scripts load async on every page. Encoding this as a tree keeps the decision reviewable — a reviewer can look at any new vendor and check that its strategy matches the branch it lands on.

Third-party loading strategy decision tree A cascade of three decisions routes each vendor to a strategy: not needed for first render leads to lazy idle load, requires consent leads to consent-gated, heavy interactive widget leads to facade on intent, and the remaining case leads to async with a timeout. New vendor request Needed for first render? Lazy / idle load Requires user consent? Consent-gated Heavy interactive widget? Facade on intent Async + timeout Yes No Yes No No Yes
Route every new vendor through the same cascade so its strategy is auditable: defer, consent-gate, facade, or async — in that priority order.

The facade branch pays the biggest dividend, so it is worth seeing on a timeline. When a chat widget or map embed loads directly, its evaluation lands inside the load window and competes with the paint that produces LCP. Behind a facade, the initial page ships only a static placeholder, LCP resolves earlier, and the vendor's full cost moves to the moment the user actually clicks — a moment the load-time budget no longer measures.

Facade timeline versus direct third-party load Two lanes on a five-second timeline: the direct-load lane shows vendor evaluation early, pushing LCP to 3.0 seconds; the facade lane ships a placeholder, reaches LCP at 2.0 seconds, and evaluates the vendor only after a user click at 3.8 seconds. Direct HTML vendor eval LCP 3.0s Facade HTML facade LCP 2.0s click vendor eval 0 1s 2s 3s 4s 5s
A facade moves a heavy vendor's evaluation out of the load window, pulling LCP from 3.0 s to 2.0 s at P75 while the vendor still runs when the user actually needs it.

Step-by-Step Implementation

  1. Inventory current third parties. Capture transfer size, parse time, and evaluation cost for every external origin so budgets are derived from data, not guesses.

    npx lhci collect --url=$STAGING_BASE_URL --numberOfRuns=3
    npx lighthouse $STAGING_BASE_URL --only-audits=third-party-summary \
      --output=json --output-path=./third-party.json

    Expected output: a third-party-summary audit listing each entity with transferSize and blockingTime in milliseconds.

  2. Set per-vendor ceilings in third-party-budget.yml at 110 to 115 percent of the measured P75 value, leaving headroom for vendor drift but not for sprawl.

  3. Convert heavy widgets to facades. Wrap chat, video, and map embeds with mountFacade so they load on intent rather than on page load.

    node -e "require('./load-third-party.js')" && echo "loader wired"

    Expected output: loader wired, confirming the module parses and exports cleanly.

  4. Lock the CSP allowlist to exactly the origins in the manifest, then commit both files so the gate has a baseline to assert against.

Threshold Calibration

Do not adopt these numbers blind — derive each ceiling from your own field data, set the lab assertion 10 to 15 percent tighter to absorb the lab-to-field gap, and confirm the percentile methodology against Percentile-Based Threshold Tuning. The values below are representative starting points at P75 on a mid-range mobile device over Fast 3G.

Vendor class Byte ceiling (gzip, P75) Main-thread ceiling (P75) Loading strategy
Tag manager 60 KB 200 ms Consent-gated
Analytics 45 KB 120 ms Async
Chat / support widget 70 KB 150 ms Facade on intent
Ad network 90 KB 250 ms Lazy / idle
A/B test / personalization 40 KB 100 ms Async, anti-flicker capped

Keep the aggregate third-party transfer ceiling under 200 KB and aggregate main-thread time under 600 ms at P75; tag managers in particular hide compounding cost, which is why they get their own treatment in Managing Third-Party Tag Manager Budgets. Set a new vendor to warn for two baseline weeks before promoting it to error, so the gate earns trust before it blocks merges. If your product is a single-page app, remember that vendors re-fire on soft navigations, so the aggregate ceiling must hold across route changes too — the mechanics of that live in the Single-Page App Performance Budgets reference.

Measuring Per-Vendor Cost in the Field

Lab numbers set the gate, but the ceiling is only credible if it is derived from real users. A short PerformanceObserver attributes long tasks and resource timing to third-party origins in the field, then ships the aggregate to your beacon pipeline where it is bucketed to P75. This is the field half of the loop; the collection and aggregation infrastructure is described in Custom Performance Beacons and RUM.

// third-party-attribution.js — attribute long tasks and bytes to vendor origins
const VENDOR_HOSTS = [
  "googletagmanager.com",
  "google-analytics.com",
  "example-chat.com",
  "example-net.com",
];
const cost = Object.create(null);

function bucket(url) {
  const host = VENDOR_HOSTS.find((h) => url.includes(h));
  return host || null;
}

// Attribute transfer bytes per vendor origin.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const host = bucket(entry.name);
    if (!host) continue;
    cost[host] = cost[host] || { bytes: 0, blockingMs: 0 };
    cost[host].bytes += entry.transferSize || 0;
  }
}).observe({ type: "resource", buffered: true });

// Attribute long-task blocking time to the attributed script, when available.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const attr = entry.attribution && entry.attribution[0];
    const src = attr && attr.containerSrc ? attr.containerSrc : "";
    const host = bucket(src);
    if (!host) continue;
    cost[host].blockingMs += Math.max(0, entry.duration - 50);
  }
}).observe({ type: "longtask", buffered: true });

// Flush the per-vendor cost map when the page is hidden.
addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden") return;
  navigator.sendBeacon("/rum/third-party", JSON.stringify(cost));
}, { once: true });

Run this against production traffic for at least a full week so weekday and weekend traffic mixes are both represented, then set each ceiling at 110 percent of the P75 bucket. A vendor whose field P75 blocking time drifts above its ceiling for three consecutive days is a regression even if no code changed on your side — that is exactly the signal a static byte gate cannot produce, and it is why the field loop and the CI loop both matter.

CI Enforcement Snippet

This GitHub Actions job builds, collects Lighthouse runs, and asserts the third-party resource summary, surfacing a required status check that branch protection can gate on.

name: Third-Party Budget Gate
on:
  pull_request:
    branches: [main]

jobs:
  third-party-budget:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run build
      - name: Assert third-party budgets
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The matching lighthouserc.json assertions cap third-party bytes and request count, and warn on aggregate main-thread work:

{
  "ci": {
    "collect": { "numberOfRuns": 3, "settings": { "preset": "perf" } },
    "assert": {
      "assertions": {
        "resource-summary:third-party:size": ["error", { "maxNumericValue": 204800 }],
        "resource-summary:third-party:count": ["error", { "maxNumericValue": 12 }],
        "third-party-summary": ["warn", { "maxNumericValue": 600 }],
        "bootup-time": ["warn", { "maxNumericValue": 2000 }]
      }
    }
  }
}

Because third-party constraints operate independently from first-party code, keep this gate distinct from your JavaScript Bundle Size Limits check so a vendor regression and a bundle regression fail with different, actionable messages.

Troubleshooting and Edge Cases

  • Vendor sharded across origins → a tag manager may inject from several hostnames; aggregate them under one logical entity in third-party-summary rather than budgeting each shard separately.
  • Consent banner inflates the baseline → exclude the consent script from the gate (--ignore-urls=".*consent.*") and budget the gated vendors against their post-consent load.
  • Facade flicker on activation → preconnect to the vendor origin and reserve the widget's box with aspect-ratio to prevent a layout shift when the real embed mounts; a flicker here shows up as a CLS regression, not a byte one.
  • CSP blocks a legitimate vendor update → the vendor changed CDN hostnames; update the script-src allowlist in the same PR that updates the manifest so both move together.
  • Async script still blocks LCP → the vendor uses document.write or a synchronous XHR; quarantine it behind a facade or drop it — async cannot rescue a synchronous internal call.
  • Aggregate budget passes but one vendor dominates → add a per-vendor assertion, not just the aggregate, so a single bloated tag cannot consume the whole allocation silently.
  • Byte gate green but field INP regressed → the vendor shipped the same bytes but heavier evaluation; this is why the field attribution loop above tracks main-thread milliseconds independently of transfer size.

Frequently Asked Questions

How do I budget a third party I do not control?

You control the boundary, not the payload. Set a per-vendor byte and main-thread ceiling, load the script async behind a timeout, and assert resource-summary:third-party:size in CI. If the vendor exceeds its ceiling, the gate fails and the integration is rejected until the vendor ships a lighter build or you move it behind a facade. The budget is a contract the vendor must fit, not a value you negotiate after shipping.

What is a facade and when should I use one?

A facade is a lightweight placeholder — a styled button or static preview — that loads the real third party only when the user shows intent by clicking or hovering. Use it for chat widgets, video embeds, maps, and anything below the fold, because it removes the vendor's full cost from the initial load budget entirely. See Image and Media Weight Budgets for the related media-embed case.

Should third-party budgets be separate from my JavaScript bundle budget?

Yes. Keep them as distinct CI assertions so a vendor regression and a first-party regression produce different failure messages and route to different owners. Bundle limits are covered in JavaScript Bundle Size Limits; a shared budget hides which side caused the breach.

Why gate on main-thread milliseconds when a byte budget is simpler?

Because bytes and evaluation cost drift apart. A vendor can ship the same transfer size but heavier parsing and execution, which regresses INP at P75 on mid-range mobile while the byte gate stays green. Budget both: bytes catch payload bloat, main-thread milliseconds catch execution bloat, and only the pair covers the real user-visible cost.

How do I keep third-party budgets from breaking on single-page-app route changes?

Vendors re-initialize on soft navigations, so a per-page budget that passes on first load can be breached across a session of route changes. Measure the aggregate across a representative navigation sequence, not just the entry page, and assert the soft-navigation cost separately — the full approach is in Single-Page App Performance Budgets.