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.
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 theresource-summary:third-partyassertion family used to gate vendor bytes and counts.- A versioned budget manifest —
third-party-budget.jsoncommitted alongside application code so every change is reviewable in a pull request. - CSP header control — the ability to emit a
Content-Security-Policyheader from your edge, server, or meta tag, so thescript-srcallowlist 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
-
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.jsonExpected output: a
third-party-summaryaudit listing each entity withtransferSizeandblockingTimein milliseconds. -
Set per-vendor ceilings in
third-party-budget.ymlat 110–115% of the measured P75 value, leaving headroom for vendor drift but not for sprawl. -
Convert heavy widgets to facades. Wrap chat, video, and map embeds with
mountFacadeso 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. -
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-summaryrather 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-ratioto prevent a layout shift when the real embed mounts. - CSP blocks a legitimate vendor update → the vendor changed CDN hostnames; update the
script-srcallowlist in the same PR that updates the manifest so both move together. - Async script still blocks LCP → the vendor uses
document.writeor 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.