Third-Party Script Performance on Static Sites

A static site ships pre-rendered HTML, cached at the edge, with no server work per request. Then someone adds an analytics tag, a chat widget, a consent manager and two embeds, and the page that scored 99 in the lab now takes four seconds to become interactive on a mid-tier Android phone. Every one of those additions was reasonable in isolation; the page's performance is decided by their sum.

This guide covers how to budget third-party code, how to defer or replace the expensive parts, when proxying through your own domain pays off, and how to measure each script's real cost in main-thread time rather than bytes. It sits under Performance Optimization & Core Web Vitals for SSGs, next to JavaScript Hydration & Partial Rendering, which deals with the JavaScript you wrote yourself.

Main-thread time by origin on a documentation page A stacked bar showing 940 milliseconds of main thread work on a mid-tier mobile device. First-party code accounts for 180 milliseconds, the tag manager 120, the analytics library 90, the chat widget 340 and the video player 210. Third-party code is four fifths of the total. Who is using the main thread on a "static" page First party 180 ms Tag mgr 120 ms Analytics 90 ms Chat widget 340 ms Video player 210 ms 940 ms total · 760 ms of it third party · measured with the Lighthouse third-party summary What the reader gets for that time Page content rendered from HTML before any script runs Measurement page views, vitals 3-5 KB is enough Support chat used by <1% of readers loaded by 100% The widget nobody opens is usually the largest single cost on the page
Third-party code accounted for 81% of main-thread time on this page, and the largest contributor served a feature fewer than one in a hundred readers used.

Budget in Milliseconds, Not Kilobytes

Bytes are easy to measure and a poor proxy for cost. A 40 KB analytics library that parses a config and sends one beacon might use 30 ms of main thread; a 40 KB widget that builds a shadow DOM, injects styles and installs a dozen listeners can use 400 ms. Interaction to Next Paint punishes the second and barely notices the first.

Set the budget as main-thread time on a mid-tier device — 150 ms of third-party execution is a workable ceiling for a content site — and get the number from Lighthouse's third-party summary or a Performance recording's Bottom-Up view filtered by URL:

npx lighthouse https://example.com --only-audits=third-party-summary \
  --form-factor=mobile --throttling-method=simulate --output=json \
  | jq '.audits["third-party-summary"].details.items[]
        | {entity: .entity, blocking: .blockingTime, transfer: .transferSize}'

Run it against a preview deploy on every pull request and fail the build when the total crosses the budget — the wiring is the same as any other CI gate, as described in GitHub Actions for Automated SSG Builds.

ScriptTransferBlocking timeCost per KB
Self-hosted vitals beacon3 KB4 ms1.3 ms/KB
Privacy-first analytics6 KB18 ms3.0 ms/KB
Tag manager (empty container)34 KB120 ms3.5 ms/KB
Consent platform58 KB190 ms3.3 ms/KB
Support chat widget96 KB340 ms3.5 ms/KB

Load Order Is a Policy Decision

Every third party belongs in one of four tiers, and the tier determines how it loads:

  1. Blocking-critical — the consent manager, because everything else waits on its verdict. Load it early, keep it small, and make the banner a fixed overlay so it cannot shift the page.
  2. Deferred-useful — analytics and vitals collection. defer or load on requestIdleCallback; they lose nothing by starting after first paint.
  3. Interaction-gated — chat, feedback, share widgets. Load on first click or hover of a static placeholder, so the 340 ms is paid only by readers who use it.
  4. Viewport-gated — heavy embeds below the fold. Load when an IntersectionObserver says they are close, with the space already reserved.
<!-- Tier 3: the widget costs nothing until someone wants it -->
<button id="chat-launcher" class="chat-launcher">Chat with support</button>
<script>
  document.getElementById('chat-launcher').addEventListener('click', () => {
    const s = document.createElement('script');
    s.src = 'https://widget.example-chat.com/embed.js';
    s.async = true;
    document.head.appendChild(s);
  }, { once: true });
</script>

That five-line pattern is the highest-value change on most content sites: it removed 340 ms of blocking time and 96 KB from every page load on the site measured above, while keeping the feature for the readers who use it.

Four loading tiers for third-party code A timeline divided into four tiers. Blocking-critical scripts run before first paint. Deferred-useful scripts run after first paint in idle time. Interaction-gated scripts run only on a click. Viewport-gated scripts run when the reader scrolls the element into view. Decide the tier once, and the loading code follows first paint 1 · blocking-critical consent 2 · deferred-useful analytics · idle 3 · interaction-gated chat · on click 4 · viewport-gated embed · on scroll Only tier 1 is paid by every reader on every page — everything else is conditional
Most third-party code sits in tier 1 by accident rather than by decision. Moving each script to the latest tier that still works is where the time comes back.

Proxy What You Can, Replace What You Cannot

Each third-party origin costs a DNS lookup, a TLS handshake and a fresh connection — 100 to 300 ms on a cold mobile network before a single byte of script arrives. Serving the file from your own domain removes all three and lets you set your own cache headers, which pairs directly with the policies in CDN Caching Rules for SSGs.

// Cloudflare Worker: serve a pinned vendor script from your own origin
export default {
  async fetch(request, env, ctx) {
    const upstream = 'https://cdn.example-analytics.com/v4.2.1/script.js';
    const cache = caches.default;
    let res = await cache.match(request);
    if (!res) {
      res = new Response((await fetch(upstream)).body, {
        headers: { 'content-type': 'text/javascript',
                   'cache-control': 'public, max-age=86400, immutable' },
      });
      ctx.waitUntil(cache.put(request, res.clone()));
    }
    return res;
  },
};

Pin the version rather than proxying a floating URL: an unpinned proxy silently ships whatever the vendor pushed, which is the same supply-chain exposure as the original script plus the illusion of control. Check for upstream changes on a schedule and bump deliberately.

Where proxying is not allowed by the vendor's terms, the question becomes whether you need the script at all. A first-party page-view counter and the web-vitals beacon cover what most content teams actually look at — the approach in Self-Hosting Analytics to Cut Third-Party Requests — and it removes an entire connection from the critical path.

Facades for Heavy Embeds

Video players, maps and social embeds ship hundreds of kilobytes to render something the reader may never interact with. A facade — a static image or styled box that loads the real embed on click — keeps the feature and removes the cost:

EmbedReal embedFacadeSaved on first load
YouTube player780 KB, 310 ms24 KB poster, 2 ms756 KB, 308 ms
Interactive map640 KB, 280 ms40 KB static tile, 3 ms600 KB, 277 ms
Social post embed210 KB, 140 ms6 KB blockquote, 0 ms204 KB, 140 ms

The step-by-step version, including keeping the facade accessible to keyboard and screen-reader users, is in Lazy-Loading YouTube Embeds on Static Sites. Because a facade and its replacement rarely have identical sizes, pair it with the reservation patterns from Fixing CLS From Late-Loading Embeds.

Measure the Interaction Cost, Not Just the Load

Load-time metrics undersell third-party code, because the expensive part often happens later. A chat widget that polls every ten seconds, an analytics library that recomputes on every scroll event, a personalisation script that re-renders a rail when the tab regains focus — none of these appear in a Lighthouse run that ends at five seconds, and all of them show up in Interaction to Next Paint.

Record a 30-second Performance trace with the page idle and read the Bottom-Up view grouped by URL. Anything that appears while nothing is happening is a background cost you are paying on every page for the whole session:

// Log long tasks and their attribution for a minute — paste in the console
new PerformanceObserver((list) => {
  for (const task of list.getEntries()) {
    if (task.duration < 50) continue;
    const src = task.attribution?.[0]?.containerSrc || task.attribution?.[0]?.name || 'unknown';
    console.log(`${Math.round(task.duration)} ms  ${src}`);
  }
}).observe({ type: 'longtask', buffered: true });

On the documentation site above, this surfaced a 90 ms task firing every 10 seconds from the chat vendor's presence poll — invisible in every lab report, and enough to make the page feel sticky on a mid-tier device while a reader scrolled. The fix was the interaction gate: no chat script, no poll.

Field data closes the loop, because it captures the devices and session lengths a lab run never reproduces. The collection pipeline in Measuring INP on Static Sites With Real-User Monitoring reports the interaction target as well as the number, which is usually enough to name the vendor responsible.

SignalWhat it catchesWhat it misses
Lighthouse third-party summaryLoad-time bytes and blocking timeAnything after the run ends
Long-task observer (idle page)Polling, timers, background re-rendersOne-off costs during load
Field INP with attributionReal interaction latency on real devicesCause, unless attribution is enabled

Keep an Inventory and Audit It

Third-party code accumulates. A quarterly audit answers three questions per script: who owns it, what decision it informs, and what it costs in main-thread time. Anything without a clear answer to the first two is a candidate for deletion, and deletion is the only optimisation that always works.

# List every third-party origin the built site references
grep -rhoE 'https?://[a-z0-9.-]+' dist --include='*.html' \
  | sed -E 's#https?://([^/]+).*#\1#' | sort | uniq -c | sort -rn \
  | grep -v 'example\.com'

Run it in CI and diff against a committed allowlist so a new origin appears in code review rather than in a Lighthouse report three months later. Keep the allowlist in the repository next to the content, with a one-line comment per origin naming the owner and the reason. When someone leaves the team, the file is the only record of why a script exists, and an unexplained entry is the easiest deletion you will ever make.

The audit is also the moment to check what each vendor has become. Scripts grow: an analytics library that was 12 KB two years ago is often 40 KB today, with features nobody on your team enabled. Compare the current transfer size and blocking time against the numbers you recorded at adoption, and treat a doubling as a reason to re-open the decision rather than a fact of life. The full audit workflow, including reading the Lighthouse treemap to attribute bytes to entities, is in Auditing Third-Party Scripts With Lighthouse.

A consent platform is the one third party that genuinely belongs early, and that makes it the most expensive one to get wrong. Every gated script waits for its verdict, so its own load time is added to the start time of everything downstream. On the site measured above, a 190 ms consent script delayed analytics by 190 ms — and delayed the decision about the chat widget by the same amount, which pushed the widget's 340 ms into the window where the reader was trying to scroll.

How a consent gate serialises downstream scripts Two sequences. In the naive setup the consent script loads for 190 milliseconds, then analytics runs, then the chat widget loads, finishing at 720 milliseconds. In the improved setup consent is self-hosted and 40 milliseconds, analytics runs in idle time and chat is gated behind a click, so the main thread is clear by 260 milliseconds. The gate decides when everything else may start Vendor consent, everything gated consent 190 ms analytics chat widget 340 ms · main thread busy to 720 ms Self-hosted consent, tiered loading 40 ms analytics idle thread clear chat only if clicked 0 ms 400 ms 800 ms Same features, same consent guarantees — 460 ms less main-thread work before the reader can interact
Gating is not the problem; serialising is. Shrinking the gate and moving the gated work out of the load path recovers most of the time without changing what the reader consents to.

Three things reliably help. Self-host the consent bundle if the vendor's licence allows, so it does not pay for its own connection. Keep the categories small — a site with three cookie categories needs a fraction of the logic a site with twelve does. And separate the gate from the load: let the consent state resolve into a simple flag, then let each script decide for itself when to start, rather than having the platform inject all of them the moment consent lands. That last change alone converts a burst of simultaneous script execution into a sequence the browser can schedule around.

When You Cannot Remove It

Sometimes a script is non-negotiable: a legal requirement, a vendor contract, a marketing system the team depends on. Three techniques limit the damage without removing the script.

Run it off the main thread. Tools in the Partytown family relocate a third-party script into a web worker and proxy its DOM access back to the main thread. It works well for analytics and tag managers, which mostly read a few globals and send requests, and badly for anything that renders UI. Expect to spend a day validating that the vendor still receives what it expects.

Bound it in time. Wrap the loader so a script that has not initialised within a few seconds is abandoned rather than left retrying:

const withTimeout = (src, ms = 4000) => new Promise((resolve, reject) => {
  const s = document.createElement('script');
  const timer = setTimeout(() => { s.remove(); reject(new Error(`timeout: ${src}`)); }, ms);
  s.src = src;
  s.async = true;
  s.onload = () => { clearTimeout(timer); resolve(); };
  s.onerror = () => { clearTimeout(timer); reject(new Error(`failed: ${src}`)); };
  document.head.appendChild(s);
});

A vendor outage then costs a rejected promise instead of a page that never settles — the reason a hung third-party connection can dominate a bad-day percentile even when the median looks healthy.

Contain what it can reach. A Content Security Policy with an explicit script-src allowlist keeps a compromised or rewritten vendor script from pulling in a second, unreviewed payload. It costs nothing at runtime and turns "our analytics vendor was compromised" into a blocked request rather than an incident. Ship it as a header from your edge layer, which on a static host is the same place your cache rules live.

Common Pitfalls

  • Trusting async to make a script free. It only unblocks parsing; execution still competes with rendering and interaction on the same thread.
  • Tag managers as a bypass. A container that anyone can edit turns performance work into a race against configuration changes. Audit it like code.
  • Measuring on a fast machine. A desktop absorbs 340 ms without a visible symptom; a mid-tier phone does not. Always throttle to 4× CPU.
  • Proxying a floating version. You inherit the vendor's changes with none of their testing. Pin and bump deliberately.
  • Loading the consent manager late. Everything downstream waits on it, so a late banner delays every gated script and, worse, shifts the page when it appears.
  • Optimising the small ones. Rank by blocking time and fix the top two; the tail rarely matters.

Key Takeaways

  • Budget third-party code in main-thread milliseconds, not kilobytes — 150 ms is a workable ceiling for a content site.
  • Assign every script a loading tier, and move each one to the latest tier that still delivers its value.
  • Interaction-gating a chat widget is usually the single largest available win.
  • Proxy pinned vendor files through your own domain to remove a connection, or replace them with a first-party equivalent.
  • Use facades for video, maps and social embeds, and reserve their space so the swap costs no layout shift.
  • Keep an origin allowlist in CI so new third parties arrive through code review.

FAQ

How much third-party JavaScript is too much on a static site?

Budget by main-thread time rather than bytes: 150 milliseconds of third-party execution on a mid-tier mobile device is a reasonable ceiling for a content site. That usually works out at 60 to 90 kilobytes compressed, but two scripts of identical size can differ tenfold in execution cost, so time is the honest unit.

Does loading a script with async or defer make it free?

No. Both keep the script from blocking parsing, but the execution still happens on the main thread and still competes with rendering and interaction. Deferring changes when you pay, not whether you pay.

Is proxying a third-party script through my own domain worth it?

It removes a DNS lookup, a TLS handshake and a separate connection, which typically saves 100 to 300 milliseconds on a cold mobile connection, and it lets you cache the file on your own terms. The trade-off is that you own updating it, so pin a version and check for changes on a schedule.

In the head, loaded early, because everything else waits on its decision. Keep it small and self-hosted if the vendor allows it. The banner element itself must be a fixed overlay so that showing it never shifts the page.

Can a static site use a tag manager without wrecking performance?

It can, but a tag manager converts a code review into a runtime configuration change, which is how sites end up with scripts nobody remembers adding. If you keep one, audit its container quarterly and treat each contained tag as part of your performance budget.

What is the cheapest analytics option for a static site?

A first-party endpoint that receives a beacon, or a lightweight self-hosted script in the 1 to 5 kilobyte range. Both avoid a third-party connection entirely and give you the page-view and vitals data most content sites actually use.