Optimizing third-party script loading
Third-party JS (analytics, ads, chat widgets, A/B tools) is usually the worst offender on real-user TTI. Defer everything by default with `async` or `defer`, load post-interaction or on `requestIdleCallback`, sandbox in a Web Worker (Partytown) when feasible, and budget the total third-party weight. Self-host the loader where the vendor allows.
Third-party scripts are the single largest source of "we don't know what changed" performance regressions. Vendors push updates without telling you; their scripts execute on your origin and can block your main thread for hundreds of ms.
Default placement.
<!-- worst: blocks parser and execution -->
<script src="https://vendor/widget.js"></script>
<!-- async: fetches in parallel, executes ASAP (out of order) -->
<script async src="..."></script>
<!-- defer: fetches in parallel, executes after DOMContentLoaded, in order -->
<script defer src="..."></script>
<!-- type=module: deferred by default -->
<script type="module" src="..."></script>async is right for independent scripts (analytics, ads). defer is right for scripts that depend on DOM but don't need to block parsing (most widgets). Never use plain <script> for third-party.
Tier them by criticality.
| Tier | Examples | When to load |
| ---- | ----------------------- | ---------------------------------- |
| 0 | Auth, error reporter | Early, in <head> with async |
| 1 | Analytics (page view) | After load, async, with sampling |
| 2 | A/B test framework | Inline blocker for FOUC, then defer|
| 3 | Chat widget, support | On interaction or after idle |
| 4 | Ads, marketing pixels | On idle, with timeout |Load-on-interaction. A chat bubble doesn't need to load before the user moves the mouse:
let loaded = false;
function loadWidget() {
if (loaded) return;
loaded = true;
const s = document.createElement("script");
s.async = true;
s.src = "https://vendor/widget.js";
document.head.appendChild(s);
}
["mousemove", "scroll", "keydown", "touchstart"].forEach(e =>
addEventListener(e, loadWidget, { once: true, passive: true })
);This pushes a 300KB chat widget out of the critical path entirely.
Load on idle. requestIdleCallback runs during browser idle slots:
(window.requestIdleCallback ?? setTimeout)(loadAnalytics, { timeout: 3000 });The timeout guarantees execution even on perpetually-busy pages.
Web Worker isolation: Partytown. A real fix for the "third-party JS owns my main thread" problem. Partytown proxies the third-party script into a worker; window access goes through a sync XHR to the main thread, where the host approves/forwards. Suitable for analytics, tag managers, ads. Not suitable for scripts that need synchronous DOM access (most chat widgets, A/B testers needing to mutate the DOM before paint).
Self-host where you can. For Google Analytics / GTM and similar, vendors increasingly support a first-party endpoint or self-hosted loader. Removes a TLS handshake, gives you Service-Worker caching, and avoids vendor downtime taking your page down.
Use <link rel="preconnect"> for unavoidable third-party origins.
<link rel="preconnect" href="https://api.vendor.com">
<link rel="preconnect" href="https://cdn.vendor.com" crossorigin>Resolves DNS + TLS during head parsing, so the eventual fetch is faster. Limit to ~4 — preconnects compete with other resources for connection slots.
Subresource Integrity (SRI).
<script src="https://cdn/lib.js"
integrity="sha384-..."
crossorigin="anonymous"></script>If the vendor's CDN is compromised and serves modified JS, the browser refuses to execute. Mandatory for anything you load from a third-party origin onto a sensitive page.
A Content-Security-Policy gates what scripts can load. Allow-list specific vendor origins. Block inline scripts (script-src 'self' 'nonce-xxx'). New script injections from a compromised tag manager get refused.
Measure with the right metric. TBT (Total Blocking Time) and INP catch third-party main-thread cost. Chrome's "Third-party usage" entry in Lighthouse summarizes per-vendor TBT. Set per-vendor budgets and treat regressions as bugs.
Senior framing. The performance question isn't "is this script async?" It's "does this script's value justify its TBT, INP, and bandwidth cost?" Most product teams don't audit. The senior thing is to make that audit a release-gate metric.
Follow-up questions
- •Difference between async, defer, and type=module for script tags?
- •How does Partytown isolate third-party scripts?
- •Why is preconnect more useful than dns-prefetch in 2026?
- •How would you set a performance budget for third-party JS?
Common mistakes
- •Loading the chat widget in the initial bundle.
- •Using plain `<script>` instead of async/defer.
- •Trusting vendor sizes — their stub loader pulls in megabytes.
- •No SRI on third-party scripts hitting auth-gated pages.
Performance considerations
- •TBT and INP are the third-party-impact metrics; track them per-page in RUM.
- •Long Tasks API surfaces tasks > 50ms — attribute them to vendor origins.
- •Idle loading: pages with constant activity (chat, live feeds) never hit idle; use a timeout.
Edge cases
- •Vendor pushes a buggy update — your site breaks. Self-host or pin versions.
- •Privacy regulation (GDPR) requires consent before loading — wire consent state to your loader.
- •Single-page apps need to re-fire "pageview" on route change — vendor scripts often miss this.
Real-world examples
- •Shopify checkout uses worker-thread tag execution for analytics. Many news sites use Partytown to keep ads off the main thread.