Putting the chat widget on a diet
Our chat widget runs on your website. Not ours. That distinction decides basically every technical argument about it.
Code on your own site competes with itself. Ship a slow dashboard and you've inconvenienced people who already chose you. Code on someone else's site competes with their Lighthouse score, their conversion rate, their rendering budget. Being heavy isn't a tradeoff you get to make on their behalf.
The widget is 23 KB gzipped. Here's how it got there, including the part where we made it bigger on purpose.
It started as hand-written DOM
The first version had no framework. One class, about 1,600 lines, building elements with document.createElement and keeping references to the ones it needed later.
That's fine at first. It stops being fine around the twentieth field of state. Ours held the current view, the ticket list, the open ticket, the WebSocket, the reconnect attempt count, a polling timer, a typing flag, a debounce timer, unread stamps, message ordering — plus a dozen cached DOM nodes to write updates into.
Every state change meant finding the right node and updating it by hand. Add a feature, thread another field through. The bug that finally made the decision: after sending a message we cleared the composer before the network call, so a failed send silently ate what you'd typed.
That's not a bug you fix. That's a bug the architecture keeps producing.
Preact, not React
Rewriting as components was obvious. Which library wasn't.
React 19 with react-dom is roughly 45 KB gzipped. Preact with hooks and the compat layer is about 7 KB. Same component model, same hooks, same JSX. For our purposes, the same library.
Forty extra kilobytes buys you a lot in an application. On someone else's marketing page it buys you nothing at all. Preact it was.
The tidy part: the components are written against react normally. Only the widget's esbuild config aliases it.
alias: {
react: "preact/compat",
"react-dom": "preact/compat",
}
The source imports react. The bundle gets Preact.
The alias paid for something we didn't expect
There's a branding studio in the dashboard where you set your colours and see a live preview of the widget. Before the rewrite, that preview was a reimplementation — it called the SDK's HTML-string builders, dumped them into a shadow root with innerHTML, then poked the header title with querySelector for the thread view.
It drifted. Of course it drifted. Two pieces of code drawing the same picture always do.
Because the components compile against react, the Next.js app can import and render the exact same <Panel> with real React 19, while the widget bundle renders it with Preact. The preview isn't a copy any more. It's the thing itself. That whole file collapsed into:
<Panel branding={resolved} view={view} open ... />
The alias was a size decision. It turned out to also be a correctness one.
Where the bytes actually are
After the rewrite, gzipped:
| Preact (core + hooks + compat) | ~7 KB |
| Our components and state | ~5 KB |
| Stylesheet | ~4 KB |
| Icons | ~4 KB |
| Everything else | ~3 KB |
Two things worth saying about that.
The stylesheet is bigger than the state management. It's one template string of design tokens and component CSS, and it resolves to hex rather than CSS variables because the widget lives inside a shadow root on a page whose theme it can't read. If we ever need another few kilobytes back, that's where they are — not in the framework.
And the icons cost more than they should. We went from emoji to a real icon set, which fixed a genuine problem: 📎 and 📄 are drawn by the visitor's operating system, so the attach button looked like a different product on every device and could never take your brand colour. But then we added a picker letting you choose the launcher icon, and a runtime choice can't be tree-shaken. All seven ship whichever one you pick. Two kilobytes, spent knowingly, so the thing looks like yours.
The number is the point
23 KB isn't a boast. It's roughly a fifth of what several widgets in this category weigh, and it's the difference between "sure, add it" and "let me check with the person who owns our Core Web Vitals."
Every feature request against the widget now gets asked in kilobytes. It's a good discipline. It's also why the answer to some of them is no.