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.
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.
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/cliversion 0.13 or newer — supplies theresource-summary:third-partyassertion family used to gate vendor bytes and counts.- A versioned budget manifest —
third-party-budget.ymlcommitted 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 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.
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.
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 to 115 percent 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 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-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; 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-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.
- 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.