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.

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.

Prerequisites & 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 ≥ 0.13 — supplies the resource-summary:third-party assertion family used to gate vendor bytes and counts.
  • A versioned budget manifestthird-party-budget.json 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 4G at P75, not a desktop on fiber; vendor cost is dominated by CPU evaluation, which the profile in Device & 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 4G
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.

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–115% 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–15% 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 4G.

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.

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 & 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.
  • 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.

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