Skip to content

Loading a chat widget without wrecking your Core Web Vitals

The vendor snippet is a synchronous script in the head. Move it to next/script with lazyOnload, measure the difference, and keep the click working while it downloads.

Crisp5 min readships at docs/solutions/crisp/loading-chat-without-hurting-lcp.md

Tags: crisp · performance · lcp · inp · next-script · third-party

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:

StrategyWhen it runsVerdict for chat
beforeInteractiveBefore any Next.js codeNever. It puts a vendor ahead of your app
afterInteractiveEarly, after some hydrationStill competes with your JavaScript
lazyOnloadBrowser idle timeCorrect
workerIn 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:

  1. 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.
  2. 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.
  3. 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 await it. No component should render null until the widget is ready, and nothing should block on it. If client.crisp.chat is 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.