# 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.

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

```html
<!-- 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`

```tsx
"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:

```ts
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:

```tsx
"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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
