2.3 — Third‑party scripts¶
Module 2 · Lesson 3 · 🟢 Foundational · ~40 min
What you'll learn¶
- How to quantify each third party's cost in milliseconds, so the conversation is about facts
- Loading strategies:
next/script, facades, consent gating, lazy triggers - Server‑side tag management — the structural fix
- How to run the political process of removing a tag without losing
On most large commerce sites, third‑party scripts are the single largest controllable cost, frequently 40–60% of main‑thread time. They are also the least owned by engineering. This lesson is as much about process as code.
Start by pricing them¶
You cannot win an argument with marketing using adjectives. Produce a table with milliseconds.
Method 1 — WebPageTest request blocking (best evidence)¶
Run the page normally, then block one vendor's domains and re‑run. The delta is that vendor's cost.
# WebPageTest script — repeat once per vendor
blockDomains chat-vendor.example.com
navigate https://www.auroramarket.com/p/wool-overshirt-navy
Method 2 — DevTools Bottom‑Up, grouped by URL¶
Record a trace, open Bottom‑Up, group by URL (or "Third Parties" if available). You get total self‑time per script in about ten seconds.
Method 3 — Long Animation Frames API, in the field¶
This is the one that scales, because it measures your real users rather than one synthetic run:
// lib/monitor-third-party.ts
// Attributes long animation frames to the scripts responsible, in production.
export function monitorLongAnimationFrames() {
if (!('PerformanceObserver' in window)) return;
if (!PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')) return;
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
for (const script of entry.scripts ?? []) {
// sourceURL, duration, invoker (e.g. 'TimerHandler:setTimeout')
reportMetric({
name: 'long_script',
value: script.duration,
attribution: {
url: new URL(script.sourceURL, location.href).hostname,
invoker: script.invoker,
invokerType: script.invokerType,
blockingDuration: entry.blockingDuration,
},
});
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
}
Aggregate by hostname and you get a leaderboard of who is actually costing your users time — in production, on real devices, weighted by real traffic.
Aurora's inventory¶
This is the artifact that starts the conversation. Every site should have one.
| Vendor | Bytes (gz) | Main‑thread | Blocks? | Business owner | Verdict |
|---|---|---|---|---|---|
| Tag manager (container) | 68 KB | 340 ms | Yes | Marketing | Move server‑side |
| Analytics (via TM) | 41 KB | 180 ms | No | Analytics | Server‑side |
| A/B testing (anti‑flicker snippet) | 34 KB | 220 ms | Yes, blocking | Growth | Edge assignment |
| Session replay | 52 KB | 290 ms | No | UX Research | Sample at 2%, defer |
| Live chat | 96 KB | 410 ms | No | Customer Service | Facade |
| Reviews widget | 44 KB | 160 ms | No | Merchandising | Server‑render, drop widget |
| Ad retargeting × 2 | 38 KB | 120 ms | No | Performance Mktg | Delay to idle |
| Affiliate pixel | 6 KB | 20 ms | No | Partnerships | Server‑side |
| Consent manager | 28 KB | 140 ms | Yes, blocking | Legal | Keep, but optimize |
| Total | 407 KB | 1,880 ms |
1,880 ms of main‑thread time on a device where you have a ~350 ms budget. This table, presented once, funded six weeks of work at Aurora.
Loading strategies with next/script¶
import Script from 'next/script';
// beforeInteractive — blocks hydration. Almost never correct.
// Legitimate uses: consent managers that MUST run first, bot/fraud detection,
// polyfills required by everything else.
<Script src="https://consent.example.com/cmp.js" strategy="beforeInteractive" />
// afterInteractive (default) — loads after hydration. For things needed early
// but not before paint: the tag manager, if you must keep it client-side.
<Script src="https://tm.example.com/container.js" strategy="afterInteractive" />
// lazyOnload — during browser idle after everything else. Correct for most tags:
// chat, retargeting, session replay, heatmaps.
<Script src="https://ads.example.com/pixel.js" strategy="lazyOnload" />
// worker — runs in a web worker via Partytown (experimental).
// See 8.2; powerful but fragile with scripts needing sync DOM access.
<Script src="https://analytics.example.com/a.js" strategy="worker" />
Decision rule:
Does the page render incorrectly without it? → beforeInteractive (rare)
Is it needed for the first interaction? → afterInteractive
Everything else → lazyOnload
Is it pure measurement with no DOM needs? → move it server-side entirely
At Aurora, exactly one script legitimately needed beforeInteractive (the consent manager,
for legal reasons). Everything else had been set that way by copy‑paste from vendor docs. Vendor
installation guides always say "put this as high in <head> as possible" because it's optimal for
them, not for you.
Facades: the single best trick¶
A facade is a lightweight, static stand‑in that loads the real widget only on interaction. Chat widgets are the canonical case: 96 KB and 410 ms of main‑thread time, used by under 2% of sessions.
// components/chat-facade.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
export function ChatFacade() {
const [loaded, setLoaded] = useState(false);
const load = useCallback(() => {
if (loaded) return;
setLoaded(true);
const s = document.createElement('script');
s.src = 'https://chat-vendor.example.com/widget.js';
s.async = true;
s.onload = () => {
// Open the real widget immediately so the click isn't lost
(window as any).ChatVendor?.open?.();
};
document.body.appendChild(s);
}, [loaded]);
// Warm the connection when the user shows intent, so the click feels instant
useEffect(() => {
const onIntent = () => {
const l = document.createElement('link');
l.rel = 'preconnect';
l.href = 'https://chat-vendor.example.com';
document.head.appendChild(l);
window.removeEventListener('pointermove', onIntent);
};
window.addEventListener('pointermove', onIntent, { once: true, passive: true });
return () => window.removeEventListener('pointermove', onIntent);
}, []);
if (loaded) return null; // real widget owns the UI now
return (
<button
onClick={load}
onMouseEnter={load} // desktop: load on hover, before the click
onTouchStart={load} // mobile: load on touch-start, before touch-end
aria-label="Open chat with customer service"
className="fixed bottom-5 right-5 z-50 flex h-14 w-14 items-center justify-center
rounded-full bg-neutral-900 text-white shadow-lg"
>
<ChatIcon />
</button>
);
}
The onMouseEnter/onTouchStart preloading is what makes this invisible to users: by the time
the click registers, the script is usually already downloading.
Facade candidates on a commerce site:
| Widget | Typical cost | Facade trigger |
|---|---|---|
| Live chat | 60–150 KB | Click / hover on the bubble |
| YouTube/Vimeo product video | 500 KB–1.2 MB | Click on a poster image (lite-youtube-embed) |
| Google Maps (store locator) | 300–600 KB | Click on a static map image |
| Reviews widget | 40–120 KB | Scroll into view, or server‑render the content |
| Social embeds | 100–400 KB each | Click, or replace with a static card |
| Size‑guide / fit‑predictor | 80–200 KB | Click on "Size guide" |
Aurora facaded chat and the reviews widget: −140 KB, −570 ms main‑thread on every PDP, and the 2% of users who wanted chat lost ~300 ms on first open. Nobody complained.
Deferring to idle, with a safety net¶
For tags that must run but not now:
// components/deferred-tags.tsx
'use client';
import { useEffect } from 'react';
const IDLE_TIMEOUT = 4000; // ship them by 4s even if the browser never goes idle
export function DeferredTags() {
useEffect(() => {
const load = () => {
loadScript('https://ads.example.com/retargeting.js');
loadScript('https://affiliate.example.com/pixel.js');
};
// requestIdleCallback isn't available in all browsers; timeout is the fallback
const id =
'requestIdleCallback' in window
? (window as any).requestIdleCallback(load, { timeout: IDLE_TIMEOUT })
: setTimeout(load, 2000);
// Also load on first meaningful interaction — user is engaged, tags should fire
const onFirstInteraction = () => load();
['pointerdown', 'keydown'].forEach((e) =>
window.addEventListener(e, onFirstInteraction, { once: true, passive: true }),
);
return () => {
'cancelIdleCallback' in window
? (window as any).cancelIdleCallback(id)
: clearTimeout(id as any);
};
}, []);
return null;
}
const loaded = new Set<string>();
function loadScript(src: string) {
if (loaded.has(src)) return;
loaded.add(src);
const s = document.createElement('script');
s.src = src;
s.async = true;
document.body.appendChild(s);
}
Watch the attribution window. Retargeting and affiliate vendors sometimes claim deferred loading loses conversions. Usually it doesn't (they fire on page events that happen later anyway), but measure it: run a 2‑week A/B with the vendor's own reported conversions as the guardrail metric. Going in with a measurement plan is what makes the vendor conversation short.
Server‑side tag management: the structural fix¶
The client‑side tag manager is the root cause. The structural answer is to move data collection to your server: the browser sends one small request to your domain, and your server fans out to vendors.
BEFORE AFTER
Browser Browser
├─ GTM container (68KB) └─ POST /api/events (2KB, sendBeacon)
├─ GA4 (41KB) │
├─ Meta pixel (22KB) ▼
├─ TikTok pixel (16KB) Your edge/server
└─ affiliate (6KB) ├─ GA4 Measurement Protocol
= 153KB, 660ms main thread ├─ Meta Conversions API
├─ TikTok Events API
└─ affiliate postback
= 0 KB client, 0 ms main thread
// app/api/events/route.ts — your own collection endpoint
import { after } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const events = await req.json();
// Respond immediately; fan out after the response is sent so the
// client never waits on vendor latency.
after(async () => {
await Promise.allSettled([
forwardToAnalytics(events),
forwardToAdsPlatform(events),
forwardToAffiliate(events),
]);
});
return new NextResponse(null, { status: 204 });
}
// lib/track.ts — client side: batch and send with sendBeacon
const queue: TrackedEvent[] = [];
let scheduled = false;
export function track(event: TrackedEvent) {
queue.push({ ...event, ts: Date.now() });
if (!scheduled) {
scheduled = true;
// Batch within a frame; flush on idle
requestAnimationFrame(() => queueMicrotask(flush));
}
}
function flush() {
scheduled = false;
if (!queue.length) return;
const payload = JSON.stringify(queue.splice(0));
// sendBeacon survives page unload and never blocks
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
}
// Always flush when the page is being hidden — this is the reliable lifecycle event
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
Honest trade‑offs of going server‑side:
| Gain | Cost |
|---|---|
| ~0 client JS, ~0 main‑thread cost | You now own an event pipeline (build + operate) |
| Immune to ad blockers (higher data completeness) | Some vendor features need client context (viewport, referrer, click IDs) — pass them explicitly |
| Better privacy control; PII never leaves your infra | Vendors' client SDKs do things like automatic form tracking; you'll re‑implement what you need |
| Marketing can't add a tag without a deploy | Marketing can't add a tag without a deploy — this is a feature and they will call it a bug |
That last row is the real conversation. The compromise that works: a server‑side tag manager (GTM Server‑Side, or a self‑hosted equivalent) — marketing keeps a UI to configure destinations, but nothing executes in the user's browser.
Consent and performance¶
Consent managers are legally required in many markets and sit at the front of the critical path. Make them cheap:
// Load the CMP itself early (legal requirement), but make the tags it gates lazy
<Script src="https://consent.example.com/cmp.js" strategy="beforeInteractive" />
// Tags wait for consent AND for idle — never fire immediately on consent grant
window.addEventListener('consent:granted', (e: any) => {
if (!e.detail.analytics) return;
requestIdleCallback(() => loadAnalytics(), { timeout: 3000 });
});
Rules:
- The CMP's own bundle should be < 30 KB. Several popular ones are 3× that; it's a valid vendor selection criterion and nobody ever asks about it during procurement.
- The banner must not cause CLS. Overlay it (
position: fixed) rather than inserting it into the document flow and pushing content down. - Do not block rendering on the consent decision; render the page, gate the tags.
- Consent state should be readable server‑side from a cookie so you can decide server‑side which tags to reference at all.
Governance: how to actually remove a tag¶
The technical work is easy. Here is the process that works:
- Publish the inventory table (above) with a named business owner per tag. Not a team — a person. Half the tags will have no identifiable owner; those are your first deletions.
- Instrument actual usage. For each tag, measure how often its feature is used. Chat: 1.8% of sessions. Size guide: 4%. Two of the three heatmap tools: zero dashboard logins in 90 days.
- Propose changes as trade‑offs, not removals. "Chat stays, and it loads on click instead of on every page — 2% of users wait 300 ms extra, 98% save 410 ms."
- Run a measured trial. Two weeks, A/B, with the vendor's own success metric as a guardrail. Vendors argue with your metrics but not with theirs.
- Institute a tag budget. New tag → something goes, or an exception with an expiry (1.5).
- Add a CI check for new third‑party origins. A PR introducing a new external host fails and requires the perf guild's review. This is the control that keeps you from doing this again in 18 months.
// scripts/check-third-party-origins.mjs — fail on unapproved external hosts
import { readFileSync } from 'node:fs';
import { globSync } from 'glob';
const APPROVED = new Set([
'images.auroramarket.com',
'consent.example.com',
'js.stripe.com',
]);
const URL_RE = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})/gi;
const violations = [];
for (const file of globSync('{app,components,lib}/**/*.{ts,tsx,js,jsx}')) {
const src = readFileSync(file, 'utf8');
for (const [, host] of src.matchAll(URL_RE)) {
if (!APPROVED.has(host) && !host.endsWith('auroramarket.com')) {
violations.push(`${file}: ${host}`);
}
}
}
if (violations.length) {
console.error('Unapproved third-party origins:\n ' + violations.join('\n '));
console.error('\nAdd to the approved list with perf-guild review, or remove.');
process.exit(1);
}
Common mistakes¶
| Mistake | Cost |
|---|---|
| Following vendor install instructions verbatim | beforeInteractive on everything |
| One tag manager loading 12 tags | You've centralized the problem, not solved it |
| Loading chat on every page for 2% of users | 96 KB + 410 ms for everyone |
A/B anti‑flicker snippet that hides <body> |
Directly delays LCP by up to its timeout |
| Session replay at 100% sampling | 290 ms per session for data nobody reviews |
Assuming async means "free" |
async doesn't block the parser; it still blocks the main thread when it executes |
| Third parties on checkout | Highest‑value page, highest risk, zero upside |
| No inventory | You can't manage what you haven't listed |
Lab 2.3 — Price your third parties¶
- Build the inventory table: every external origin, bytes, main‑thread ms, blocking status, business owner. Use WebPageTest blocking for the ms column.
- Deploy the Long Animation Frame monitor and let it run a week. Compare the field leaderboard to your synthetic table — they often disagree, and the field one is right.
- Pick the top offender that isn't legally required. Facade it or defer it.
- Measure the delta with a 2‑week A/B, tracking the vendor's own conversion metric as a guardrail.
- Add the third‑party origin CI check so the win doesn't decay.
- Present the inventory to marketing with the ms → conversion translation from 1.1.
Expected result: −200 to −900 ms TBT and a governance process worth more than the fix.
Checklist¶
- Complete third‑party inventory with bytes, ms, and a named owner per tag
- Only genuinely required scripts use
beforeInteractive - Chat, video, maps, reviews, social all behind facades
- Marketing/ads tags on
lazyOnloador idle with an interaction fallback - Consent banner overlays (no CLS) and its bundle is < 30 KB
- Zero non‑essential third parties on checkout
- Server‑side event collection for analytics and conversion tracking
- Session replay sampled (2–5%), not 100%
- CI blocks new third‑party origins without review