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:

  1. Fetches the chatbot's public settings
  2. Draws the launcher button
  3. 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.