Third-Party Script Performance: Containing Code You Did Not Write
This guide extends JavaScript Bundle Optimization & Code Splitting to the JavaScript your build never sees — the tags, widgets, embeds and consent managers that arrive at runtime from origins you do not control.
The asymmetry is what makes third-party code dangerous. Your own bundle is measured in CI, split by route, tree-shaken and versioned. A tag manager container is a runtime-editable script that a marketing colleague can change on a Tuesday afternoon, and it executes with the same privileges and on the same thread as your application code. It is completely normal to find a page whose first-party bundle is a well-optimised 140KB and whose third-party payload is 900KB across eleven origins — and where every 200ms of INP damage comes from the second number.
You usually cannot delete these scripts — they pay for the site. What you can do is decide when they run, what thread they run on, and how much they are permitted to cost.
Prerequisites
- The Coverage and Performance panels, plus the Performance panel's third-party grouping, which attributes main-thread time by origin rather than by file.
- A list of every tag currently deployed, with an owner's name against each. Half of a typical container is inherited from campaigns nobody remembers; the fastest optimisation is deletion, and deletion needs an owner to sign off.
- A staging environment where tags can be disabled individually. Measuring the cost of a tag by removing it is the only method that captures its true cost including the work it triggers in other scripts.
- A
Content-Security-Policyyou can extend, since several containment techniques change the origin a script is served from.
1. Attribute the Cost Per Origin
Aggregate numbers are useless for negotiation. You need a table that says "this vendor costs 380ms of main-thread time on mobile", because that is the sentence that gets a tag removed.
# Per-origin main-thread cost, straight out of a Lighthouse run.
npx lighthouse https://example.com --output=json --quiet --preset=desktop \
| jq -r '.audits["third-party-summary"].details.items[]
| "\(.mainThreadTime|round)ms \(.transferSize)B \(.entity.text // .entity)"' \
| sort -rn
# trade-off: Lighthouse attributes by known entity, so a tag proxied through your
# own domain is credited to you. Cross-check with the Performance panel's
# bottom-up view filtered by script URL before you defend a first-party number.
Run it on the routes that matter, not the homepage. Tag deployment is rarely uniform: checkout pages routinely carry payment, fraud and analytics scripts that the landing page never loads, and that is also where interaction latency costs the most money.
The Coverage panel adds the second half of the picture — how much of each script's bytes were actually executed. A vendor script with 12% coverage is shipping you an entire SDK to use one function. The procedure for turning coverage output into a defensible cost table is in measuring third-party cost with the Coverage panel.
2. Fix the Loading Attributes First
Before any architectural work, correct the trivially wrong cases. A surprising share of third-party damage comes from scripts loaded with the wrong attribute, and the fix is a one-line change.
| Pattern | Effect | Use for |
|---|---|---|
<script src> (no attribute) | Blocks the parser until fetched and executed | Nothing on a modern page |
<script async> | Downloads in parallel, executes as soon as it arrives — can interrupt anything | Independent tags with no ordering needs |
<script defer> | Downloads in parallel, executes in order after parsing | Scripts that touch the DOM |
| Injected on interaction | No cost until the user opts in | Chat, video, maps, social embeds |
| Worker-isolated | Runs off the main thread entirely | Analytics and tag containers |
async is the default recommendation in most vendor documentation and it is often the wrong one: an async script can execute at any moment, including in the middle of hydration or during a user interaction, which is precisely how a tag manager lands a 340ms task on top of a click. defer at least guarantees execution after parsing, in a predictable order.
3. Replace Heavy Embeds with Facades
The largest single win available on most content pages is the facade: render a lightweight, accurate-looking placeholder, and load the real embed only when the user interacts with it.
<!-- A video facade: poster image + play affordance, no vendor JS until clicked. -->
<button class="video-facade" data-video="dQw4w9WgXcQ"
aria-label="Play: Building a performance budget">
<img src="/thumbs/perf-budget.avif" width="1280" height="720" alt=""
loading="lazy" decoding="async">
<span class="play-icon" aria-hidden="true"></span>
</button>
<script type="module">
document.querySelectorAll('.video-facade').forEach((el) => {
el.addEventListener('click', () => {
const f = document.createElement('iframe');
f.src = `https://www.youtube-nocookie.com/embed/${el.dataset.video}?autoplay=1`;
f.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture';
f.title = el.getAttribute('aria-label').replace(/^Play: /, '');
f.width = 1280; f.height = 720; f.loading = 'lazy';
el.replaceWith(f);
}, { once: true });
});
</script>
<!-- trade-off: the facade costs one extra click before playback and forfeits
vendor analytics on impressions. For a video that 4% of visitors play, that
is an excellent deal; for an autoplaying hero video it is not applicable. -->
A single YouTube embed pulls roughly 500–900KB and several hundred milliseconds of main-thread time on a mobile profile; the facade above costs a 30KB image. The same pattern applies to maps, chat widgets, comment systems and social embeds. The complete implementation, including keyboard accessibility and preserving the aspect ratio so the swap causes no layout shift, is in replacing YouTube embeds with a facade.
4. Move Containers Off the Main Thread
For tags that must run on every page view — analytics, consent, experimentation — the remaining lever is the thread they run on. A worker-based sandbox executes vendor JavaScript in a Web Worker and proxies its DOM access back to the main thread asynchronously.
Worker isolation is powerful and genuinely lossy: synchronous DOM reads become asynchronous, some vendor scripts detect the sandbox and misbehave, and debugging moves into a second execution context. It suits analytics and measurement tags — which mostly write, rarely read, and tolerate a few milliseconds of latency — far better than scripts that manipulate visible UI. The comparison against simply deferring the same container is worked through in Partytown vs async script loading for tag managers.
5. Gate Everything Else Behind Intent
For the remaining tags that are neither critical nor worker-safe, the cheapest strategy is to load them when the user shows intent, or when the thread is genuinely idle — whichever comes first.
// Load non-critical tags after first interaction, with an idle-time fallback.
const loadTags = (() => {
let done = false;
return () => {
if (done) return; done = true;
for (const src of ['/tags/heatmap.js', '/tags/survey.js']) {
const s = document.createElement('script');
s.src = src; s.defer = true;
document.head.append(s);
}
};
})();
['pointerdown', 'keydown', 'scroll'].forEach((t) =>
addEventListener(t, loadTags, { once: true, passive: true }));
if ('requestIdleCallback' in window) requestIdleCallback(loadTags, { timeout: 8000 });
// trade-off: interaction-gated tags miss bounce visits entirely, which changes
// what your analytics measures. Agree the shift with the data owner before
// shipping it, or you will be asked to revert it after the first weekly report.
That last trade-off is the real obstacle to third-party work, and it is organisational rather than technical. Bring the data owner the per-origin cost table from step 1 and a proposal for what is measured instead; a change that arrives as a surprise gets reverted.
Deconstructing a Tag's True Cost
A vendor's own byte count understates its cost by a wide margin. Count all five components.
- Connection setup — DNS, TCP and TLS to a new origin costs
100–300msbefore a single byte of script arrives. Eleven origins is eleven handshakes, andpreconnectonly helps the two or three you hint. - Transfer — the reported size, usually the smallest term.
- Parse and compile — roughly
1msper KB of JavaScript on a mid-tier mobile CPU, paid whether or not the code is used. - Execution — including the work the tag schedules later: timers, mutation observers, and its own secondary script loads. A container that loads six more scripts is six more of every line above.
- Ongoing interference — long-lived observers,
setIntervalpolling, and event listeners ondocumentthat run inside your interaction handlers and land directly in INP.
The last term is invisible in a load-time audit and is the most common reason a "small" tag hurts interaction latency. Look for it in the Performance panel by recording an interaction, not a load, and reading the bottom-up view for scripts you did not write.
Advanced Diagnostics
Attribute INP to third-party listeners. Field attribution gives you the interaction target; the Performance panel tells you which listener on that target was slow. When the answer is a vendor's delegated document listener, no amount of first-party optimisation will help — the fix is to remove, defer or sandbox that tag.
Watch for tag-triggered layout thrash. Heatmap and session-replay tools frequently read layout properties in a loop over the whole document. That forces synchronous reflow, which shows up as CLS-adjacent jank and slow scrolling. Detect it by profiling a scroll with the tag enabled and disabled.
Self-hosting a vendor script trades one risk for another. Proxying the file through your own origin removes a connection and lets you cache it properly, but you now own its update cadence and its security review. It is the right call for a stable, versioned SDK and the wrong one for a script the vendor patches weekly.
Consent managers are on the critical path by design. They render-block by necessity, and they are frequently the single largest blocking script on the page. Push for a build that is inlined, minimal and free of framework dependencies — and confirm whether it can be served from your own origin as described in CDN edge caching configuration.
Validation and Budgeting
Third-party budgets have to be enforced automatically, because the payload changes without a deploy.
{
"ci": {
"assert": {
"assertions": {
"third-party-summary": ["error", { "maxNumericValue": 250 }],
"total-byte-weight": ["error", { "maxNumericValue": 1600000 }],
"bootup-time": ["error", { "maxNumericValue": 1500 }],
"uses-rel-preconnect": ["warn", { "minScore": 1 }]
}
}
}
}
Run this on a schedule against production, not only on pull requests. A tag added through a container never touches your repository, so a PR-only gate cannot see it — a nightly run against the live site is what catches the Tuesday-afternoon change.
Pair the lab budget with a field alert on INP segmented by whether the visit loaded the tag in question. When a new tag ships, that segmentation answers the question "did it hurt?" within a day, and it answers it with the population that matters.
FAQ
Is async or defer better for a third-party tag?
defer is the safer default. Both download in parallel with parsing, but async executes the moment the file arrives, which can be in the middle of hydration or a user interaction, producing an unpredictable long task exactly when it hurts most. defer executes after parsing, in document order, so the timing is predictable and the script cannot interrupt earlier work. Reserve async for tags that must run as early as possible and have no ordering requirements.
Does self-hosting a vendor script make it faster?
It removes the connection setup and lets you apply your own caching, which is usually worth 100–300ms on a cold visit. It does not reduce parse, compile or execution cost, which is where most of the damage is. Self-host stable, versioned SDKs; leave frequently patched scripts with the vendor so you are not shipping stale code, and remember that you also inherit the security review when you host it.
Will a worker sandbox break my analytics?
It breaks anything that reads the DOM synchronously or depends on APIs the worker context lacks. Measurement tags that record page views, clicks and custom events generally work; scripts that render UI, read layout, or interrogate the DOM in tight loops generally do not. Validate event-for-event against a control before rolling out, because a silent 5% event loss is far more expensive than the milliseconds you saved.
How do I prove a specific tag is responsible for an INP regression?
Record an interaction — not a page load — in the Performance panel with the tag enabled, then disable it and record the same interaction. Compare the bottom-up view for the input handler: a vendor's delegated listener on document will show up in the first trace and vanish in the second. Confirm in the field by segmenting INP on visits that did and did not receive the tag.
Related
- Deferring non-critical analytics scripts safely — the delivery mechanics of moving a tag off the critical path.
- Reducing input delay from third-party tags — the INP-side diagnosis when someone else's listener is in your handler.
- Replacing YouTube embeds with a facade — the highest-yield single change on most content pages.
- Partytown vs async script loading for tag managers — when worker isolation beats simply deferring.
- Measuring third-party cost with the Coverage panel — build the per-vendor cost table that wins the argument.
- Hydration & Partial Rehydration Strategies — protect the main thread you just freed from your own framework.