You add live chat on Friday. On Monday your Lighthouse score has dropped fifteen points, LCP on the pricing page has gone from 1.9s to 3.1s, and the Vercel Speed Insights graph has a step in it exactly where the deploy landed. Nothing looks slower to you, because you are on a fast laptop on office wifi and the script is already in your browser cache.
Every hosted chat widget does this, and the reason is the snippet they ask you to paste.
What the snippet actually does
<!-- the vendor's copy-paste snippet: DON'T -->
<script type="text/javascript">
window.$crisp = [];
window.CRISP_WEBSITE_ID = "00000000-0000-4000-8000-000000000000";
(function () {
const d = document;
const s = d.createElement("script");
s.src = "https://client.crisp.chat/l.js";
s.async = 1;
d.getElementsByTagName("head")[0].appendChild(s);
})();
</script>
The inline part is synchronous: the parser stops, executes it, and only then
continues. That is small. The expensive part is what l.js does next: it
pulls a much larger bundle, opens a websocket, mounts an iframe and starts
rendering the launcher. All of that competes for the main thread during the
exact window in which the browser is trying to paint your largest element and
respond to the first click.
Three separate metrics suffer:
- LCP: main-thread contention delays the paint of your hero.
- INP: long tasks from the widget's own initialisation sit between a user's tap and the response.
- CLS, occasionally, if the launcher pushes anything around as it mounts.
The fix: next/script with lazyOnload
"use client";
import Script from "next/script";
import { useEffect } from "react";
export function CrispWidget() {
const websiteId = process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID;
// Globals must exist before l.js runs; it reads them at load.
useEffect(() => {
if (!websiteId) return;
window.$crisp = window.$crisp ?? [];
window.CRISP_WEBSITE_ID = websiteId;
}, [websiteId]);
if (!websiteId) return null;
return <Script id="crisp-widget" src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
}
lazyOnload tells Next.js to inject the script during browser idle time, after
hydration and after the page's own work. The four strategies, ranked by how
defensible they are for a chat bubble:
| Strategy | When it runs | Verdict for chat |
|---|---|---|
beforeInteractive | Before any Next.js code | Never. It puts a vendor ahead of your app |
afterInteractive | Early, after some hydration | Still competes with your JavaScript |
lazyOnload | Browser idle time | Correct |
worker | In a web worker (experimental) | Not compatible with a widget that needs the DOM |
The visible effect is that the launcher appears a second or two after the page becomes usable. That is not a defect. Nobody has ever bounced because the chat bubble arrived late; plenty of people bounce because the page took three seconds to paint.
Why the command queue makes this safe
The one thing that scares people about deferring the script is losing the calls
made before it loads. That is what window.$crisp = [] is for. Until l.js
arrives, $crisp is a plain array and every command you push simply sits in it.
When the real client loads, it replaces the array and replays the queue in
order.
So this works, whether or not Crisp has finished loading:
export function openChat(): void {
window.$crisp = window.$crisp ?? [];
window.$crisp.push(["do", "chat:open"]);
}
A user who clicks "chat with us" one second after the page loads gets the chat
opening as soon as the script lands, rather than nothing happening. Set
aria-busy on your button so assistive technology says the same thing.
Going further: load on intent
If your pages are performance-critical, or the widget is only relevant to a minority of visitors, do not load it at idle either: load it when someone shows intent. Render your own lightweight button, and inject the script on first click:
"use client";
import Script from "next/script";
import { useState } from "react";
export function SupportOnDemand() {
const [wanted, setWanted] = useState(false);
return (
<>
<button
type="button"
onClick={() => setWanted(true)}
className="rounded-pill border border-hairline px-4 py-2 text-button text-body-strong"
>
Chat with us
</button>
{wanted ? (
<Script
id="crisp-widget"
src="https://client.crisp.chat/l.js"
strategy="lazyOnload"
onLoad={() => window.$crisp?.push(["do", "chat:open"])}
/>
) : null}
</>
);
}
Cost: the first click waits for the download, typically a few hundred milliseconds on a normal connection, with the chat window opening straight after. Benefit: visitors who never click (the overwhelming majority) pay nothing at all. For a marketing site this is almost always the right trade.
Measure it, do not assume it
Assumptions about third-party cost are usually wrong in both directions. Measure:
- Lighthouse, twice. Once with the widget rendered, once with the
<Script>commented out. Compare LCP, TBT and the "Reduce the impact of third-party code" audit, which lists the script's main-thread time in milliseconds. - DevTools Performance panel, throttled to Slow 4G and 4× CPU. Record a
load and look for long tasks attributed to
client.crisp.chat. On a mid- range phone the initialisation is easily 200-400ms of main thread. - Field data. Lab numbers are a proxy. Vercel Speed Insights or PostHog's web vitals will tell you whether real users saw the change, and they are the only numbers that count.
Expect roughly: beforeInteractive costs you real LCP; afterInteractive costs
some TBT and INP on slow devices; lazyOnload is close to free; load-on-intent
is free.
The things that are still worth checking
- Do not
awaitit. No component should rendernulluntil the widget is ready, and nothing should block on it. Ifclient.crisp.chatis blocked by an extension or down entirely, the product must be unaffected. - Handle
onError. Log a warning, do nothing else. A chat widget failing to load is not an error boundary's business. - One loader. Rendering the
<Script>in two places gives you two launchers and a set of duplicate-session bugs that are genuinely hard to diagnose. Keep it in one component, mounted once from the root layout. - Preconnect is not free either. Adding
<link rel="preconnect">for the chat CDN speeds up a load you deliberately deferred, at the cost of a connection during the critical window. Skip it unless you have measured that the widget arriving sooner matters.