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.
- Record a Performance trace of a cold load with 4× CPU throttling. Find the
Evaluate Scriptentry for the chat vendor’s bundle and note its duration. - In the same trace, expand Long animation frames (or the
longtaskentries) and check how many name the vendor’ssourceURL. - 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. - 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.
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.
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.
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) orscheduler.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.