← Recipes

阅读中文版本

Defer Third-Party Scripts Without Breaking Their Features

Audit what each external script costs on the critical path, load analytics and widgets post-interaction, and verify events still arrive.

~35 minfrontendperformancethird-party

Task

Move third-party scripts off the initial load path while preserving their data collection and functionality, verified end to end.

When to use this

Six vendor scripts load eagerly: 340KB of JavaScript before content, a 3.4-second LCP, and the chat widget freezing the main thread while nobody even opens it.

Prerequisites

  • Chrome DevTools network and performance panels
  • A page with multiple third-party embeds (analytics, chat, marketing)
  • A test account or debug mode in each vendor to confirm events arrive

Steps

1. Audit each script: size, blocking time, and actual usage.

echo "DevTools > Network > JS > sort by size; note 3rd-party rows"; echo "then Performance > record load; find long tasks attributed to vendor frames"; grep -o "https://cdn[^\"]*" dist/index.html | sort -u | head -10

Expected output: A per-vendor cost table emerges: analytics 92KB, chat 140KB, marketing tag 108KB, each with its blocking duration from the performance trace.

2. Classify by when the feature is actually needed.

<!-- class A: never needs eager load (analytics) -->
<!-- class B: needed on likely interaction (chat after 3s idle or on click) -->
<!-- class C: needed for first paint (nothing third-party should be here) -->

<!-- A: load after first paint via requestIdleCallback -->
<script>
  const loadAnalytics = () => {
    const s = document.createElement('script');
    s.src = 'https://cdn.vendor-a.com/analytics.js';
    s.async = true;
    document.head.appendChild(s);
  };
  if ('requestIdleCallback' in window) {
    requestIdleCallback(loadAnalytics, { timeout: 2000 });
  } else {
    setTimeout(loadAnalytics, 2000);
  }
</script>

<!-- B: chat as a click-to-load facade -->
<button data-chat-facade>
  <span aria-hidden="true">💬</span> Chat with support
</button>
<script>
  const btn = document.querySelector('[data-chat-facade]');
  btn.addEventListener('click', () => {
    btn.disabled = true;
    const s = document.createElement('script');
    s.src = 'https://cdn.vendor-b.com/chat.js';
    s.onload = () => btn.dispatchEvent(new Event('chat:open'));
    document.head.appendChild(s);
  }, { once: true });
</script>

Expected output: Analytics loads after paint without competing with content; the chat widget costs zero bytes until a visitor actually asks for it.

3. Preserve data collection contracts while deferring.

// vendor SDK typically expects a queue that survives late loading
window.dataLayer = window.dataLayer || [];      // GA4 pattern
window.chatq = window.chatq || [];              // generic queue pattern

// enqueue events before the script arrives
function track(event, payload) {
  if (window.__analyticsReady) {
    window.__analytics.send(event, payload);
  } else {
    window.dataLayer.push({ event, ...payload }); // replayed on load
  }
}

// flush the queue when the SDK initializes
window.addEventListener('analytics:ready', () => {
  window.__analyticsReady = true;
  window.dataLayer.splice(0).forEach((e) =>
    window.__analytics.send(e.event, e));
});

Expected output: Events fired before the deferred script loads are queued and replayed, so deferral costs no data rather than trading metrics for speed.

4. Add a CSP and SRI so deferred loading is also safer.

Content-Security-Policy:
  script-src 'self'
    https://cdn.vendor-a.com/analytics.js
    https://cdn.vendor-b.com/chat.js;

<script
  src="https://cdn.vendor-a.com/analytics.js"
  integrity="sha384-BEV693irYkZMDaWgpC2vP5Ff3EpWz0lqc3EugYv3Rc+"
  crossorigin="anonymous"></script>

Expected output: The allow list pins exactly which vendors may execute, and SRI makes a compromised CDN deliver a failed script instead of running arbitrary code.

5. Verify speed improved and every vendor still receives events.

echo "1) reload with Performance panel: LCP and long tasks before/after"; echo "2) open chat facade, confirm widget initializes and session starts"; echo "3) check vendor debug view (GA4 DebugView or chat console): queued events arrived"; echo "4) verify CSP report endpoint shows no blocked-needed scripts"

Expected output: LCP drops by seconds with zero long tasks from vendors; the chat opens on click, and the vendor debug consoles show every queued event replayed after load.

Troubleshooting

Problem

Vendor script requires document.write or sync placement

Solution

It cannot be deferred as-is; load it inside the facade click handler or an iframe sandbox so its legacy behavior cannot block the host page, and request a modern build from the vendor.

Problem

Events fired during deferral never arrive

Solution

The queue is not being replayed; confirm the SDK init callback dispatches the analytics:ready event and that the splice(0) flush runs after it, logging queue length at flush time.

Problem

Marketing insists on eager loading for attribution

Solution

Load their tag on first interaction (any click or scroll) instead of never; it preserves nearly all attribution windows while keeping the initial path clean, and the numbers show the trade.

Limitations

  • Deferred scripts still run eventually; CPU cost on low-end devices is deferred, not eliminated, so keep total third-party bytes under budget.
  • Facade patterns change UX slightly (click to load); align with product on copy and expectations before shipping.

Every vendor script should justify its seat on the critical path

Six eager third-party scripts cost 340KB before the visitor sees content, and most of their features are never used in a session. Classifying by need-time and loading on idle or interaction keeps every feature available while the page renders first.

audit (before):
  vendor-a analytics   92KB  blocks 610ms   never needs eager
  vendor-b chat       140KB  blocks 890ms   needed on click only
  vendor-c marketing  108KB  blocks 540ms   needed on interaction
  LCP 3.4s, 4 long tasks from vendor frames

after (idle + facade + queued events):
  initial third-party bytes: 0KB
  analytics: requestIdleCallback, 2s timeout
  chat: click facade -> 1.2s to interactive
  marketing: first scroll/click
  LCP 1.5s, zero long tasks
  GA4 DebugView: page_view + signup events arrived (replayed)

Queue-then-replay is the data contract

Deferring a script defers its receiver, so events fired in the gap vanish unless you buffer them. The window.dataLayer push pattern is industry-standard precisely because late loading is normal; your wrapper plus a ready-event flush makes the queue a guarantee instead of a convention.

The facade is a product decision

A chat button that becomes a real widget on click trades a moment of loading for seconds of first-paint. That is usually the right trade, but the copy and the loading state are user-facing choices. Ship it as a designed interaction, not as an engineering shortcut that looks like a bug.