Your field Interaction to Next Paint sits just over 200 ms, the lab trace shows a live-chat SDK evaluating for roughly 300 ms during load, and the widget’s launcher bubble is something most visitors never open.

Triage: Separate the Load Cost from the Session Cost

A chat widget is unusual among third parties because it hurts twice. It costs main-thread time during load, and it costs interaction latency for the rest of the session, because most chat SDKs install global listeners on document to detect engagement. Confirm both before choosing a fix — they have different remedies, and only one of them is solved by deferring the load.

  1. Record a Performance trace of a cold load with 4× CPU throttling. Find the Evaluate Script entry for the chat vendor’s bundle and note its duration.
  2. In the same trace, expand Long animation frames (or the longtask entries) and check how many name the vendor’s sourceURL.
  3. Interact with an unrelated control — a filter, a menu, a form field — and look for the vendor in the scripts[] attribution of the resulting long animation frame. A vendor appearing in interactions it has nothing to do with is a global listener.
  4. Pull the field numbers: in CrUX or your own RUM, compare INP p75 for sessions where the widget opened against sessions where it did not.
Two costs, not one An upper band shows the load cost: a single 300 millisecond script evaluation block early in the page load, which deferring the widget removes entirely. A lower band shows the session cost: a small recurring block attached to each of four unrelated user interactions across the session, caused by a global document listener. A note records that deferring the load removes the first cost and postpones but does not eliminate the second, which only disappears if the widget is never opened. Load cost — one block, early evaluate SDK ≈ 300 ms deferring removes this entirely Session cost — many blocks, spread out tap filter open menu type in form submit a global document listener runs on interactions that have nothing to do with chat Deferring postpones the lower band and eliminates it for the majority who never open the widget.

Root Cause: The Launcher Is Cheap, the SDK Is Not

What the visitor sees before engaging is a bubble in the corner — a circle, an icon, sometimes an unread badge. What the page loads to draw it is the full messaging client: a websocket transport, a message store, an emoji picker, a file-upload path, localisation bundles, and the engagement heuristics that decide when to pop the widget open unprompted. Those heuristics are why the SDK binds global listeners: it wants to know whether you are idle, scrolling, or about to leave.

That makes chat the clearest case for the facade reasoning in lazy-loading third-party embeds on interaction, with one difference from a video embed. A video facade waits for a click on the thing itself. A chat launcher is fixed to the viewport and always visible, so there is no “approach” to observe — which is why the trigger has to be a proxy for intent rather than proximity to the widget.

The proxy that works well is progress through the content. A reader who reaches the FAQ, the pricing table, or the foot of a support article is measurably more likely to ask a question than one who bounced from the first screen. IntersectionObserver on a sentinel placed at that point turns “reached the part of the page where people ask questions” into a load trigger, at no polling cost.

Resolution: Sentinel, Idle Callback, Then Load

The implementation has three stages, and each exists to avoid a specific failure. The sentinel decides whether; the idle callback decides when within that; and a guard makes the load happen once.

<!-- A real launcher, first-party, ~1 KB. It is a button from the start, so it is
     reachable and announced correctly whether or not the SDK ever loads. -->
<button id="chat-launcher" type="button" class="chat-launcher"
        aria-label="Open live chat">
  <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
       stroke-width="2" aria-hidden="true">
    <path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9 9 0 0 1-3.8-.8L3 21l1.9-4.6A8.4 8.4 0 0 1 12 3a8.4 8.4 0 0 1 9 8.5z"/>
  </svg>
</button>

<!-- Placed in the markup at the point where questions start: end of the FAQ,
     the pricing table, or the last section of a support article. -->
<div id="chat-sentinel" aria-hidden="true"></div>
// chat-lazy.js — deferred. Without it the launcher is present but inert,
// which is a coherent degraded state rather than a broken one.
(function () {
  const launcher = document.getElementById('chat-launcher');
  const sentinel = document.getElementById('chat-sentinel');
  if (!launcher) return;

  let state = 'idle';                       // idle → loading → ready

  function loadSdk() {
    if (state !== 'idle') return;           // sentinel and click can both fire
    state = 'loading';

    const s = document.createElement('script');
    s.src = 'https://widget.chat-vendor.example/sdk.js';
    s.async = true;
    // Injected scripts start at Low priority; this one is now user-relevant.
    s.fetchPriority = 'high';
    s.crossOrigin = 'anonymous';
    s.onload = () => {
      state = 'ready';
      launcher.dataset.ready = 'true';
    };
    s.onerror = () => {
      state = 'idle';                       // allow a retry on the next click
      launcher.setAttribute('aria-label', 'Live chat unavailable — retry');
    };
    document.head.appendChild(s);
  }

  // Trigger 1 — the reader reached the part of the page where questions start.
  // rootMargin pre-loads slightly before the sentinel is actually visible.
  if (sentinel && 'IntersectionObserver' in window) {
    const io = new IntersectionObserver((entries, obs) => {
      if (!entries.some((e) => e.isIntersecting)) return;
      obs.disconnect();
      // Yield first: arriving at the sentinel usually coincides with scrolling,
      // and a 300 ms evaluation during a scroll is exactly what INP punishes.
      const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 200));
      schedule(loadSdk, { timeout: 3000 });
    }, { rootMargin: '200px 0px' });
    io.observe(sentinel);
  }

  // Trigger 2 — explicit intent always wins, wherever the reader is.
  launcher.addEventListener('click', () => {
    if (state === 'ready') return;          // the SDK owns the click from here
    loadSdk();
    launcher.setAttribute('aria-label', 'Opening live chat…');
  });
})();

Two details carry most of the value. The requestIdleCallback wrapper matters because the sentinel almost always crosses the viewport during a scroll, and scheduling a 300 ms evaluation into an active scroll is the precise pattern that produces a poor INP sample — the same reasoning behind the scheduling guidance in priority hints for script execution. The timeout: 3000 bounds the yield so a permanently busy main thread cannot postpone the load forever. And fetchPriority = 'high' is set before the element is appended, because a dynamically injected script is otherwise classified Low regardless of how much the visitor now wants it.

Three states, two triggers, one load A state machine with three states. Idle is the initial state, with the launcher present but no SDK. Two transitions lead from idle to loading: the sentinel intersecting the viewport, which first yields to an idle callback, and a direct click on the launcher, which loads immediately. Loading transitions to ready on script load, at which point the vendor owns the launcher. Loading transitions back to idle on script error, so a later click can retry. A guard on entry to loading prevents a second load when both triggers fire. idle launcher only, ~1 KB sentinel visible → yield to idle launcher clicked → load now loading guard blocks a second entry onload ready vendor owns the launcher onerror → back to idle, click can retry Returning to idle on error is what makes the launcher recoverable rather than permanently dead.

Verification: INP Attribution Before and After

The check that matters is field-side, because the whole point is the session cost. Two weeks after release, compare the p75 of event_duration attributed to the chat vendor between the release cohorts, using the join described in attributing INP regressions to a single vendor script. Sessions that never reached the sentinel should show the vendor absent from LoAF attribution entirely — not merely reduced.

Lab-side, one trace confirms the load half: record a cold load without scrolling, and the vendor’s Evaluate Script entry must be absent. If it appears, something other than the sentinel is still referencing the SDK — most often a tag manager rule firing the same vendor independently.

Where the Sentinel Belongs

The sentinel’s position is the whole design, and it is a content decision rather than an engineering one. Placed too early it fires for everyone and the deferral achieves nothing; placed too late it fires after the visitor has already given up looking for help.

Sentinel position decides who pays A page shown three times with the sentinel at different depths. Placed in the hero, it intersects immediately on load, so every visitor loads the widget and the deferral achieves nothing. Placed at the end of the FAQ, it loads for the subset who read that far, which correlates well with having a question. Placed in the footer, it loads only for visitors who have already reached the bottom, most of whom are leaving. in the hero loads for 100 % end of the FAQ loads for the readers who got that far in the footer loads for visitors already leaving

Instrument the candidate positions before choosing between them. A custom event fired when each depth is reached, compared against your widget’s open rate, turns this from a guess into a measurement — and the answer differs between a documentation page and a pricing page in ways nobody predicts correctly in advance.

Common Pitfalls

  • Placing the sentinel above the fold. A sentinel in the hero intersects on load, so the observer fires immediately and you have written an elaborate way to load the SDK eagerly. Put it where questions actually arise, and confirm in DevTools that it is genuinely off-screen at load on a small viewport.

  • Loading synchronously inside the observer callback. The callback runs during scroll. Injecting and evaluating a 300 KB SDK there converts a scroll into a janky frame and a poor INP sample — the exact metric the change was meant to protect. Always yield through requestIdleCallback (with a timeout) or scheduler.postTask.

  • Leaving the vendor’s own auto-loader in place. Most chat vendors ship a bootstrap snippet that injects the SDK on DOMContentLoaded. If you add lazy loading without removing that snippet, the SDK loads eagerly and again from your code, doubling the cost you set out to remove.


Frequently Asked Questions

Where exactly should the sentinel go?

At the point in the page where a reader’s questions become concrete — the end of an FAQ block, immediately after a pricing table, or the final section of a support article. The test is empirical rather than aesthetic: instrument the position with a custom event for a week and compare how often reaching it is followed by a widget open against your site-wide open rate.

Avoid the footer. By the time a reader reaches it they are usually leaving, so the SDK loads for people who will never use it while still missing the ones who wanted it mid-page. If a single position does not fit your templates, place several sentinels and observe them all — the observer disconnects on the first intersection, so extra sentinels cost nothing after the load.

What should happen if the visitor clicks the launcher before the SDK finishes loading?

Acknowledge the click and open the widget as soon as the SDK is ready. The state machine above already prevents a second load, but the visible half matters just as much: update the launcher’s accessible name to a loading state, and once onload fires, call the vendor’s open method rather than waiting for a second click.

Most SDKs expose a queue for exactly this — a global array that the bundle drains on initialisation. Pushing an “open” command into that queue at click time is more reliable than trying to invoke the API after load, because it works whether the click arrived before or after the script finished.

Does deferring the widget reduce the number of conversations?

Usually not, but this is the measurement to run before rolling it out widely, because it is the one that decides whether the change is worth keeping. Compare conversations started per session between cohorts, not conversations per page view — deferring removes the widget from sessions that were never going to converse, so a per-page-view figure will move even when behaviour has not changed.

Where a drop does appear, it is nearly always caused by the vendor’s proactive invitations, which cannot fire before the SDK exists. If those invitations drive meaningful volume for you, move the sentinel earlier rather than abandoning the pattern — you keep most of the saving and restore the prompt for readers who are engaged enough to reach it.


Up: Lazy-Loading Third-Party Embeds on Interaction