Most visitors never open a chat widget, but every one of them downloads it.
That was the problem we wanted to avoid when building an embeddable chat widget. Customers paste one script tag on their site, so our code runs on pages we don't control, on connections we can't predict. It has to be small, it can't break the host page, and it has to work under a strict Content-Security-Policy.
Here is what we ended up with:
| File | Gzipped | When it loads |
|---|---|---|
| Loader | about 4 KB | Every page view |
| Full chat | about 20 KB | First time it's needed |
Split the widget in two
The script tag only loads the loader. It does three things:
- Fetches the chatbot's public settings
- Draws the launcher button
- Waits
The chat UI, message rendering and everything else live in a second file. If nobody opens the chat, that file is never downloaded.
Start the download on intent, not on click
Waiting for a click means the visitor stares at a button while a file downloads. So we start earlier, as soon as they show interest:
const preload = () => loadChat().catch(() => { /* retried on click */ });
['pointerenter', 'focus', 'touchstart'].forEach((event) =>
button.addEventListener(event, preload, { once: true, passive: true })
);
button.addEventListener('click', open);
Keyboard users get the same head start through focus. On click, the button switches to its open state immediately, so there is feedback while the file finishes loading.
Don't cache a failed load forever
Keeping one shared promise stops the file loading twice. But if that promise rejects and you keep it, one network blip breaks the chat until the next page load. So we clear it on failure:
let chatPromise = null;
function loadChat() {
if (window.__CHAT__) return Promise.resolve(window.__CHAT__);
if (chatPromise) return chatPromise;
chatPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = chatUrl;
script.async = true;
script.onload = () => resolve(window.__CHAT__);
script.onerror = () => reject(new Error('chat failed to load'));
document.head.appendChild(script);
}).catch((error) => {
chatPromise = null; // the next click tries again
throw error;
});
return chatPromise;
}
Give the page a working API before the chat exists
Site owners want to call things like Chat.open() from their own buttons, and they shouldn't have to know whether the chat has loaded. So the loader exposes a small facade with the same methods. Each one waits for the real chat, then forwards the call:
window.Chat = {
open: () => ensureStarted().then((chat) => chat.open()),
toggle: (state) => ensureStarted().then((chat) => chat.toggle(state)),
// ...
};
Once the chat has started, it replaces the facade as the public object.
Make the swap invisible
The placeholder button and the real one use the same markup and the same CSS file, which the build includes in both bundles. When the chat takes over, nothing jumps.
The catch is that two files now have to agree on that markup, so a test checks they stay in step.
Shadow DOM is not enough on its own
We render inside a shadow root so the host page's CSS can't reach our elements. But inherited properties still cross the boundary, so we start with this:
:host {
all: initial;
}
That still wasn't enough. A rule like this on the host page also matches our custom element, and outer styles beat :host:
* {
letter-spacing: 4px !important;
}
So the first element inside the shadow root resets everything again:
.launcher-wrapper {
all: initial;
/* then set position, fonts and so on */
}
Work under a strict CSP
Plenty of sites block inline styles and inline handlers, so templates can't use style="" or onclick. For the stylesheet, we use constructable stylesheets and fall back to a <style> tag:
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
shadowRoot.adoptedStyleSheets = [sheet];
Text from the owner's settings only ever goes in through textContent, never innerHTML.
Protect the budget with a test
Small bundles rarely stay small by accident. One careless import and the loader is suddenly carrying the whole chat. So a test fails if the loader goes over 16 KB raw or 6 KB gzipped.
The trade-offs
This setup has real costs:
- A very fast click can still wait. On a slow connection, the chat takes a moment to appear.
-
Every page view makes one settings request. We set a 4 second timeout and fall back to the last copy in
localStorage. - Some settings skip the lazy path. If a chat opens automatically, it has to load right away.
- Old and new files can meet. A cached loader can talk to a newer chat file for up to an hour, so the chat's methods have to stay backward compatible within a version.
If you have shipped a third-party embed, what caught you out on real customer sites? I'd like to hear about it in the comments.
Disclosure: I work on RSFlowHub, and this is our chat widget. The code above is simplified from it. I used an AI assistant to draft this post, and checked the numbers and behaviour against our code.
Top comments (0)