Setting a Third-Party Request Budget
Symptom: your first-party bundle has not grown in two quarters, yet p75 LCP has drifted from 2.4 s to 3.4 s, and a cold load now touches 47 third-party requests across 14 origins that no single team remembers approving.
Root cause: nothing in the pipeline resists a new tag
A tag has no marginal cost at the moment it is added. It arrives as a snippet in a ticket, it is installed through a tag manager without a deploy, and it renders nothing, so no reviewer sees it fail. The cost it does carry is paid by resources it never mentions: connection slots, link bandwidth, and main-thread time in the window before the largest contentful paint. Because that cost is shared rather than attributed, every individual addition looks free and the aggregate is a regression nobody authored.
The scheduling mechanism underneath is worth stating precisely, because the budget’s units come directly from it. There is one fetch scheduler per page, and third-party requests are tiered by the same rules as first-party ones — an async vendor script lands at Low priority in Chromium, exactly where a below-the-fold image lands. Priority governs ordering within the queue, not exclusion from the link. Chromium’s resource scheduler limits how many delayable (Low and Lowest) requests may be in flight — ten per client and six per host under normal operation, and just one while a render-blocking resource is still outstanding, a state usually called tight mode. Once the render-blocking phase ends, that gate opens and every queued vendor request dispatches at once, right in the middle of the LCP resource’s download.
From that moment the constraint is not the priority queue at all; it is congestion control. Each third-party origin is a separate connection, no coalescing applies across unrelated hosts, and TCP and QUIC both converge toward an equal share of the bottleneck per connection. If your LCP image is on the wire while k third-party connections are also transferring, the image gets roughly 1/(k+1) of the link. That is the whole regression in one fraction: a 224 KB hero that needs 0.45 s alone on a 4 Mbps link needs 3.14 s when six other connections are live. The waterfall below is the measured shape of it.
Note what the diagram does not show: any single tag that is obviously to blame. Each third party is defensible in isolation — 71 KB for analytics is cheap, and the chat widget only loads 169 KB. The regression is the sum, and a sum can only be governed by a ceiling.
Minimal reproduction
The smallest page that reproduces the contention: one hero image and six cold third-party origins, loaded under Lighthouse’s mobile throttle.
<!doctype html>
<meta charset="utf-8">
<title>Third-party contention repro</title>
<!-- The hero is declared first and carries fetchpriority="high", so this is NOT a
priority-ordering bug: the image is dispatched at Highest and still loses,
because priority orders the queue while congestion control divides the link. -->
<img src="/hero.avif" width="1200" height="675" fetchpriority="high" alt="">
<script async src="https://tags.vendor-a.example/container.js"></script>
<script async src="https://collect.vendor-b.example/collect.js"></script>
<script async src="https://bid.vendor-c.example/bid.js"></script>
<script async src="https://replay.vendor-d.example/replay.js"></script>
<script async src="https://chat.vendor-e.example/chat.js"></script>
<script async src="https://cdn.vendor-f.example/consent.js"></script>
Load it twice in Chrome DevTools with Disable cache on and the network profile set to Fast 4G, once with the six <script> tags and once with them commented out. The hero’s own bytes are identical in both runs; only its download duration changes. That is the confirmation that you are looking at a bandwidth-sharing problem rather than a fetch priority problem, and it is the reason the fix is a budget rather than an attribute.
To turn the observation into a number you can budget against, measure the pre-LCP window directly. Everything that completes before the LCP timestamp is inside the window and competes for it; everything after is a different problem:
// prelcp-census.js — the measurement the budget is written against.
// Rationale: LCP is the boundary of the contended window, so entries are
// classified by responseEnd against the LCP timestamp, not by a fixed cutoff.
const FIRST_PARTY = /(^|\.)example\.com$/;
const entries = [];
let lcpTime = Infinity;
new PerformanceObserver((list) => {
// Only the LAST LCP candidate counts; earlier ones are superseded.
lcpTime = list.getEntries().at(-1).startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((list) => {
entries.push(...list.getEntries());
}).observe({ type: 'resource', buffered: true });
addEventListener('visibilitychange', function report() {
const before = entries.filter((e) => e.responseEnd <= lcpTime);
const third = before.filter((e) => !FIRST_PARTY.test(new URL(e.name).hostname));
navigator.sendBeacon('/rum/prelcp', JSON.stringify({
lcp: Math.round(lcpTime),
origins: new Set(third.map((e) => new URL(e.name).origin)).size,
requests: third.length,
// transferSize is 0 for responses without Timing-Allow-Origin; those bytes
// are real, so they are counted separately rather than silently as zero.
bytes: third.reduce((n, e) => n + e.transferSize, 0),
opaque: third.filter((e) => e.transferSize === 0 && e.duration > 0).length,
}));
}, { once: true });
Deriving the ceilings instead of guessing them
Round numbers picked in a meeting do not survive their first argument with a vendor. Derive each ceiling from a target and a measured constant, and the argument becomes arithmetic.
The connection ceiling. From the fair-share relation, the LCP resource’s wire time is t = S × (k + 1) / B, where S is its size in bits, B the p75 downlink, and k the number of concurrent third-party connections. Solving for k gives k ≤ (t × B / S) − 1. With a 1.4 s wire-time allowance, B = 4 Mbps and S = 224 KB = 1.792 Mbit, that is k ≤ 2.1 — two concurrent third-party connections while the hero is downloading. Across the whole pre-LCP window the ceiling can be three origins, because the consent platform completes its handshake before the hero starts.
The byte ceiling. A 2.5 s LCP target on a 4 Mbps link can carry 10 Mbit, or about 1,250 KB. Handshakes, slow start and scheduler gaps waste roughly a quarter of that on a cold load, leaving ~900 KB actually deliverable. Subtract the first-party critical path — 420 KB of document, critical CSS, hero and app shell — and third parties get 480 KB before the paint.
The blocking ceiling. Total Blocking Time counts only the portion of each task past 50 ms. Holding third-party TBT to 300 ms on a 4× throttled CPU keeps the LCP paint out of a long task in the field at p75; that is the figure to divide between vendors.
The chart below is the same 900 KB expressed three ways — how it was allocated, what was actually measured, and where enforcement landed the page.
Two vendors account for the entire overshoot. That is typical, and it is why an across-the-board percentage cut is the wrong instrument: the correct move is a per-vendor ceiling that leaves the cheap tags alone.
Deterministic fix protocol
Work through these in order. Each step produces an artifact the next one consumes, and each is independently verifiable.
- [ ] 1. Fix the window. Instrument
largest-contentful-paintand classify resource entries byresponseEndagainst it, as in the census snippet above. Ship it sampled at 1–5% so the baseline is field p75, not one lab run. - [ ] 2. Record the current spend. Produce four numbers for the pre-LCP window: distinct third-party origins, requests, compressed bytes, and long-task milliseconds over 50 ms attributable to third-party script. Cross-check against the impact matrix built in third-party resource impact mapping.
- [ ] 3. Solve for the connection ceiling. Apply
k ≤ (t × B / S) − 1with your own p75 downlink and LCP resource size. Keep the derivation in the budget file as a comment — it is what you will re-run when the hero image is re-encoded. - [ ] 4. Derive the byte and blocking ceilings. Target LCP × p75 downlink, minus ~25% overhead, minus the first-party critical path. Split the remainder across vendors by measured value, not by request order.
- [ ] 5. Write the budget as data, not prose. Lighthouse consumes a budget file directly, which makes the ceilings executable rather than aspirational:
[
{
"path": "/*",
"resourceCounts": [
{ "resourceType": "third-party", "budget": 24 },
{ "resourceType": "script", "budget": 18 }
],
"resourceSizes": [
{ "resourceType": "third-party", "budget": 480 },
{ "resourceType": "total", "budget": 900 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 300 }
]
}
]
- [ ] 6. Fail the build on a breach. Point Lighthouse CI at the budget file and assert the budget audits.
performance-budgetfails on count and size breaches,timing-budgeton the metric breaches, andmaxLengthonthird-party-summarycaps how many distinct entities may appear at all:
{
"ci": {
"collect": {
"numberOfRuns": 5,
"settings": { "budgetsPath": "./budget.json" }
},
"assert": {
"assertions": {
"performance-budget": "error",
"timing-budget": "error",
"third-party-summary": ["error", { "maxLength": 6 }]
}
}
}
}
- [ ] 7. Gate injection at runtime. A CI budget cannot see a tag installed through a tag manager after the deploy. Route every injection through an admission controller that knows the remaining pre-LCP allowance:
// tag-budget.js — runtime admission control for third-party injection.
const BUDGET = { origins: 3, requests: 11, bytes: 480 * 1024 };
const spent = { origins: new Set(), requests: 0, bytes: 0 };
let paintDone = false;
new PerformanceObserver(() => { paintDone = true; })
.observe({ type: 'largest-contentful-paint', buffered: true });
export function requestTag({ src, weight = 0, window: win = 'pre-lcp' }) {
const origin = new URL(src, location.href).origin;
// After the paint the contended window is over: admit freely, but still at Low
// priority so the tag cannot preempt anything the user is interacting with.
if (paintDone || win !== 'pre-lcp') return inject(src, 'low');
const wouldExceed =
(!spent.origins.has(origin) && spent.origins.size >= BUDGET.origins) ||
spent.requests + 1 > BUDGET.requests ||
spent.bytes + weight > BUDGET.bytes;
// Deferring is the safe failure mode: the tag still runs this page view, just
// outside the window where it would take bandwidth from the LCP resource.
if (wouldExceed) return void addEventListener('load', () => inject(src, 'low'));
spent.origins.add(origin);
spent.requests += 1;
spent.bytes += weight;
return inject(src, 'low');
}
function inject(src, fetchPriority) {
const s = document.createElement('script');
s.src = src;
// Injected scripts are force-async per the HTML spec, so ordering is never
// guaranteed here; anything order-sensitive must set s.async = false itself.
s.fetchPriority = fetchPriority;
document.head.appendChild(s);
}
- [ ] 8. Census the origins you did not authorise. Deploy a report-only policy so fourth parties introduced by your third parties show up as violation reports rather than as a surprise in next quarter’s audit:
# Report-only: nothing is blocked, so this is safe to ship to 100% of traffic.
# Every origin outside the allow-list produces a report — that report stream is
# the cheapest continuous census of piggy-backed fourth parties available.
Content-Security-Policy-Report-Only: script-src 'self' https://tags.vendor-a.example https://cdn.vendor-f.example; report-to csp
Reporting-Endpoints: csp="https://example.com/reports/csp"
- [ ] 9. Assign every ceiling an owner. A budget row without a named team is a budget row nobody defends. Record vendor, purpose, owning team, byte ceiling, blocking ceiling and load window in the same file the build reads.
- [ ] 10. Alert on drift, not just on deploys. Vendors grow without your release cycle. Run the synthetic budget check nightly and alert when any entity’s field byte total or request count moves more than 20% week over week.
- [ ] 11. Re-measure and close the loop. Re-run the census after enforcement and confirm the four numbers moved. If the lab improves and the field does not, the fix is probably gated behind consent and never runs for real users.
New requests then resolve through the same path every time, which is what makes the ceiling hold under pressure from a team that wants one more tag.
Before/after metrics
Measured on the reproduction above: Chrome 140, Fast 4G profile (4 Mbps down, 120 ms RTT), 4× CPU throttle, cold cache; field columns are p75 over 14 days.
| Metric | Before | After budget enforced | Ceiling |
|---|---|---|---|
| Third-party origins contacted before LCP | 9 | 3 | 3 |
| Third-party requests before LCP | 34 | 11 | 11 |
| Third-party bytes before LCP | 992 KB | 418 KB | 480 KB |
| Total bytes before LCP | 1,412 KB | 838 KB | 900 KB |
| Hero image effective throughput | 0.57 Mbps | 1.20 Mbps | — |
| Hero image wire time | 3.14 s | 1.19 s | 1.40 s |
| LCP (lab) | 3.35 s | 1.56 s | — |
| LCP (field p75) | 3.4 s | 2.2 s | 2.5 s |
| Total Blocking Time (lab, 4× CPU) | 890 ms | 410 ms | — |
| Third-party TBT contribution | 640 ms | 210 ms | 300 ms |
| Long tasks over 200 ms before LCP | 4 | 1 | — |
| Total page requests | 61 | 38 | — |
The row that matters for governance is the last-but-one: the page did not get faster because one vendor was tuned, it got faster because three of them moved out of the contended window. Two of those moves were mechanical — the chat widget became a facade, and session replay moved behind the load event — and neither required a vendor conversation.
FAQ
Should a third-party budget be expressed in bytes or in requests?
Both, because they bind on different links. On a high-latency connection the binding constraint is the number of distinct origins: each one costs a DNS lookup, a transport handshake and a TLS handshake before a single byte arrives, so ten tiny pixels on ten hosts cost more than one 200 KB script on a warm connection. On a low-bandwidth connection the binding constraint is bytes, because every concurrent transfer takes an equal share of the bottleneck. A budget with only one dimension is trivially satisfied by moving the cost into the other, which is why the ceilings above cover origins, requests, bytes and blocking milliseconds together. Where an origin genuinely earns its place, spend a preconnect on it and record that hint against the same budget row.
How do I budget tags that only load after the consent banner is accepted?
Give them a separate window with its own ceiling. Post-consent tags never touch LCP, so a pre-LCP byte budget says nothing about them — but a dozen tags initialising inside one burst of long tasks wrecks INP, and consent is frequently accepted while the user is already scrolling. Budget that window in blocking milliseconds and in longest-task duration rather than in bytes, measure it from the consent-accepted event instead of navigation start, and make your synthetic runs click the banner. A lab run that never accepts consent reports a page with almost no third parties and a budget that always passes.
What do I do when a vendor grows past its budget with no deploy on our side?
Assume it will happen, because it is the normal failure mode: vendor bundles grow on their release cycle, not yours. That is why the budget check has to run on a schedule as well as on pull requests. When the alert fires, the responses in ascending cost are: pin the vendor to a versioned URL if they publish one; move the tag to a later load window; replace it with a facade; or self-host a reviewed copy and accept the update burden. If the vendor is a tag manager container, measure before reacting — container growth is often one new tag inside it rather than the container itself, and isolating the container’s own blocking time tells you which.
Related
- Third-Party Resource Impact Mapping — up to the parent topic: the attribution methodology this budget is derived from
- Measuring Tag Manager Blocking Time — sibling guide: isolating the container’s own execution cost from the tags it loads
- Replacing Third-Party Embeds with Facades — sibling guide: reclaiming budget from the network-heavy quadrant