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.

Waterfall of a cold page load showing six third-party connections sharing the link with the LCP image, pushing LCP to 3.35 seconds A timeline from 0 to 3.6 seconds with eight request rows. The document and the LCP hero image are first-party; six third-party origins each show a 360 ms handshake segment followed by a transfer segment. The hero image bar stretches across the whole contended window and a dashed marker records LCP at 3.35 seconds. Before the budget — 4 Mbps downlink, 120 ms RTT, cold cache index.html TTFB 180 ms · 18 KB consent.js cold origin — 3 RTT before the first byte · 58 KB hero.avif (LCP) 224 KB · averaging 0.57 Mbps — one of seven live connections container.js 94 KB · tag manager collect.js 71 KB · analytics bid.js 412 KB · ad stack replay.js 188 KB · replay chat.js 169 KB · chat widget LCP 3.35 s 0 0.6 s 1.2 s 1.8 s 2.4 s 3.0 s 3.6 s handshake (DNS+TCP+TLS) LCP image other first-party third-party transfer Seven connections share the link, so hero.avif averages 0.57 Mbps and needs 3.14 s instead of 0.45 s.

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.1two 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.

Stacked bar chart comparing the allocated 900 KB pre-LCP byte budget against 1412 KB measured and 838 KB after enforcement Three stacked columns on a kilobyte scale from zero to 1500. The allocated column totals 900 KB and sits exactly on the ceiling line. The measured column totals 1412 KB, overshooting mostly through the ad stack and the session replay plus chat segments. The enforced column totals 838 KB and sits below the ceiling. Pre-LCP byte budget: allocated, measured, enforced KB 1,500 1,200 900 600 300 0 420 KB 150 420 KB 412 KB 357 KB 420 KB 152 900 KB ceiling 900 KB allocated 1,412 KB — 57% over 838 KB — inside Allocated budget per-vendor ceilings Measured before field p75, pre-LCP After enforcement same window, re-measured first-party critical path consent platform tag manager container analytics collector ad stack session replay + chat

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-paint and classify resource entries by responseEnd against 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) − 1 with 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-budget fails on count and size breaches, timing-budget on the metric breaches, and maxLength on third-party-summary caps 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.

Decision tree routing a new tag request through the pre-LCP budget to one of four outcomes: post-LCP, admit, swap, or reject A vertical spine of three decision diamonds. The first asks whether the tag must run before the LCP paint; if not it is sent to the post-LCP window. The second asks whether a pre-LCP slot is free; if so the tag is admitted with a preconnect and a budget row. The third asks whether an existing tag can be demoted; if so the two are swapped, otherwise the request is rejected in favour of a facade or an on-interaction load. Admission control: what happens when a team asks for a new tag New tag request vendor, purpose, owner Needed before the LCP paint? no Post-LCP window inject after the LCP entry fires yes Slot free: 3 origins, 11 requests, 480 KB? yes Admit to the pre-LCP window add preconnect + a budget row no Can a cheaper tag be demoted first? yes Swap, do not add demote a tag, then re-measure no Reject or defer facade, or load on interaction Every admitted tag carries a byte ceiling, a blocking-millisecond ceiling and a load window in the budget file.

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.

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