Third-Party Scripts
Purpose
Keep third-party scripts (analytics, tag managers, chat, A/B tools, embeds) from silently destroying performance and privacy. Each script earns its place: right loading strategy, measured cost, CSP-controlled, consent-gated where required.
Universal — loading-strategy classification, performance budgeting, facade patterns, and CSP/consent controls apply to any site; only the script-injection helper differs by framework.
Procedure
Inventory every third-party script
- List all external scripts (analytics, tag managers, ads, chat, embeds, hosted fonts)
- For each: purpose, owner, business-critical? — scripts with no owner or unclear purpose are removal candidates
Assign a loading strategy per script
- Critical & needed early (consent manager, core analytics) → load high priority, but still async
- Important, not render-blocking (most analytics) → load after hydration / when the main thread is idle
- On-interaction only (chat widget, video embed) → lazy-load on click / scroll via a facade
- Never inject a render-blocking synchronous
<script>in<head>
Use a facade for heavy embeds
- Chat widgets, YouTube / maps embeds, social widgets often cost 500KB+ each
- Render a static placeholder; load the real script only on user intent (click)
- This is the single biggest win for INP / TBT on widget-heavy pages
Prefer server-side / first-party where possible
- Tag managers and analytics increasingly support server-side containers — moves work off the client
- Self-host stable scripts (fonts, small libs) to cut a third-party origin connection
Set a third-party performance budget
- Cap total third-party JS (e.g., ≤ 100KB transfer, ≤ 200ms main-thread on mid-tier mobile)
- Each new script must fit the budget or displace an existing one — measure with the WebPageTest "3rd party" breakdown or Lighthouse
Connection hints for unavoidable origins
preconnect/dns-prefetchfor the script origin (saves TCP/TLS handshake) — coordinate withapi-caching-optimization- Only preconnect the few origins that matter; each one costs a connection
Security + privacy controls
- Allowlist each script origin in CSP
script-src(coordinate withsecurity-audit) — no blanket'unsafe-inline' - Add Subresource Integrity (SRI) hash for scripts served from a fixed URL
- Consent-gate tracking scripts — don't load before opt-in where GDPR / CCPA applies. Consent must gate loading, not just configuration; in the EEA, Google Consent Mode v2 is required for ads/analytics tags, and the consent manager is the one script allowed to load first
- Tag managers are a runtime backdoor: GTM lets non-engineers inject arbitrary scripts that bypass your review, budget, and CSP — govern who can publish, audit the container, and accept that its CSP story is painful (it pushes toward
'unsafe-inline'or nonce gymnastics)
- Allowlist each script origin in CSP
Measure impact (validation loop)
- Record a trace before/after each script; if main-thread blocking or INP regresses past budget, downgrade its strategy (idle → on-interaction → facade) and re-measure
- Loop until total third-party cost fits the budget
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
Synchronous <script> in <head> for analytics |
Async, loaded after hydration / when idle |
| Chat widget loaded eagerly on every page | Facade placeholder → load on click |
Blanket script-src 'unsafe-inline' for vendors |
Allowlist each origin + nonce / SRI |
| Loading tracking before consent (GDPR) | Consent-gate loading (Consent Mode v2 in EEA); load only after opt-in |
| Tag manager any teammate can publish to | Governed publish access + container audit + CSP review |
| Adding scripts with no budget check | Each script must fit the third-party budget or displace one |
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | Render-blocking sync third-party script in <head>; tracking loaded pre-consent in a GDPR region |
Block release; fix immediately |
| Major | Eagerly-loaded heavy widget (chat / embed) inflating INP / TBT; third-party JS over budget; missing CSP allowlist; ungoverned tag manager (anyone can inject runtime scripts) | Fix this sprint |
| Minor | Missing preconnect for a used origin; unowned script of unclear value |
Schedule within 2 sprints |
Completion Criteria
- Every third-party script inventoried with purpose + owner
- Each script has an explicit loading strategy (none render-blocking)
- Heavy widgets / embeds use a facade (load on interaction)
- Total third-party JS within budget (default ≤ 100KB transfer)
- Each script origin allowlisted in CSP; SRI where applicable
- Tracking scripts consent-gated where required (Consent Mode v2 in EEA); tag-manager publish access governed
- All Critical findings fixed; all Major findings scheduled
Output
- Script inventory:
docs/third-party-scripts.md— script / purpose / owner / strategy / size / budget impact - Loading config: per-script strategy (async / idle / on-interaction) + facades for heavy embeds
- CSP update:
script-srcallowlist + SRI hashes (coordinate withsecurity-audit) - Budget report (paste into PR): total third-party transfer + main-thread time before/after
- Commit format:
perf(3p): defer <script>/feat(3p): facade for <widget>
Implementation
React + Next.js (default)
- Loading:
next/scriptwithstrategy—beforeInteractive(rare, critical only),afterInteractive(default),lazyOnload(idle),worker(Partytown, experimental — runs off the main thread; test each script, those needing direct DOM/cookies often break in a worker) - Facades: render a placeholder component, mount the real embed on click (e.g.,
react-lite-youtube-embed, lazy chat triggers) - Connection hints:
<link rel="preconnect">in the root layout - CSP:
script-srcallowlist innext.config.tsheaders()(seesecurity-audit) - Server-side: GTM server-side container; reverse-proxy analytics (PostHog, Vercel) to a first-party path
Other stacks
- Vue / Nuxt:
useHead({ script: [{ src, defer }] }), or@nuxt/scripts(purpose-built: facades, consent, perf strategies) - SvelteKit:
<svelte:head>for script tags; manual facade components; Partytown integration for worker offloading - Angular: a script-loader service (or
Renderer2) with an explicit per-script strategy; manual facades for embeds - Universal:
<script async/defer>,preconnect/dns-prefetch, CSPscript-src, SRI, and the facade pattern are web-platform standards; Partytown (web-worker offloading) is framework-agnostic
Related skills
rendering-performance— third-party JS is a top INP / TBT regression sourcebundle-optimization— first-party bundle vs. third-party loaded scripts are different leverssecurity-audit— external scripts need CSP allowlisting + SRI
Reference
- Key insight encoded: Third-party scripts are the most common cause of poor INP / TBT because they run on the main thread outside your control. Default everything to
afterInteractive/lazyOnload, use facades for heavy widgets (load on interaction), and hold every script to a measured budget — a script with no owner and no measured value is a removal candidate. Two governance traps: a tag manager is a runtime backdoor that bypasses your CSP/budget/review (govern publish access), and consent must gate script loading (Consent Mode v2 in the EEA), not just post-load configuration.