Parcelglance puts a chat widget on Shopify storefronts. Third-party scripts on a storefront are guilty until proven innocent, so I made myself a promise and encoded it as a build check: the widget ships under 50 KB gzipped, or the build fails.
For months, every deploy printed this:
> postbuild
> node scripts/check-widget-size.mjs
[widget-size] public/parcelglance-widget.js
raw: 12.24 KB
gzipped: 3.77 KB (limit 50.00 KB)
✅ Under the 50 KB gzipped claim.
Green. Every time. And meanwhile the file most merchants were actually downloading was 78 KB raw, about 75 KB over the wire, and the widget wasn't loading for a large group of them at all.
Three separate things were wrong, and the size gate was the one that made the other two invisible.
Two files, one gate, no overlap
The app had grown two ways to put a widget on a storefront.
The first was a theme app extension: a Liquid snippet that loads a loader script, which loads the real widget from /widget.js. That's the modern path, and it's the one new installs got.
The second was older: an "auto inject" toggle in the app's settings page that inserted a script tag pointing at /parcelglance-widget.js. Same feature, different file, written earlier, never deleted.
Both files sat in public/. Both were deployed. Both were reachable. They had different md5 hashes and different code.
The postbuild gate hardcoded the path of the second one. So the check that was supposed to protect my performance claim was measuring a 12 KB file that the theme extension never requested, while the 78 KB file that storefronts actually loaded was never weighed by anything.
The gate wasn't broken. It was answering a different question than the one I thought I'd asked. And because it printed a green checkmark, I stopped thinking about bundle size entirely — which is exactly what a check is for, and exactly why a check that measures the wrong artifact is worse than no check at all.
The 404 that made me look
What finally forced the issue wasn't performance. It was a support-shaped symptom: the widget doesn't appear.
Chasing it turned up a one-line bug in the injection path:
const base = apiEndpoint.replace('/chat', ''); // apiEndpoint = https://app.example.com/api/chat
const src = `${base}/widget.js`; // → https://app.example.com/api/widget.js
replace('/chat', '') leaves /api behind. So the injected tag pointed at /api/widget.js, which returned 404, while /widget.js at the domain root returned 200 with the correct 78 KB file. Every merchant installed through the theme snippet had a widget that 404'd on load. Not slow. Absent.
That's the moment the size gate became embarrassing rather than merely useless. I had a green build, a hardcoded performance budget, and a production 404 on the only asset that budget was supposed to describe. Two checks, both of which would have caught something, and neither of which was looking at the thing users got.
What I did about it
I deleted the second path rather than fixing it. Keeping two injection routes meant keeping two files in sync, and the older one existed only because I hadn't removed it.
The shipped shape now:
- One loader,
widget-loader.js, 1.7 KB gzipped. It creates a host element, attaches a Shadow DOM, renders the bubble, and does nothing else. - One widget, 17.33 KB gzipped for everything that actually loads on a storefront, against the 50 KB ceiling I set myself.
- The loader waits 3 seconds, or until the visitor interacts, before fetching the widget.
17.33 KB against a 50 KB limit means I have room I'm not using. I'm not filling it. The limit isn't a target, it's the point at which I have to justify each kilobyte to myself, and right now the honest answer is that most of what I'd add back is UI polish that costs storefront performance to make my own screenshot look better.
How the size was measured, and how it was measured wrong twice
Two measurement mistakes sat under the gate.
The first was measuring gzip of a file I had compressed myself rather than what the CDN sends. If you gzip -9 a file locally you get one number; if you curl it with Accept-Encoding: gzip you get what the browser actually pays, which depends on the server's compression level and whether it's compressing at all. Those two numbers can differ by 30% on JS. The gate was doing the local compression, which is the flattering one.
curl -s -H 'Accept-Encoding: gzip' -o /dev/null \
-w '%{size_download}\n' https://app.example.com/widget.js
That's the number that matters, and it's the number I now check after deploy.
The second mistake was asserting on a path instead of on the served response. A file in public/ is not a guarantee about what a storefront's browser fetches: a stale deploy, a rewrite rule, a different code path, or a 404 all produce a working build and a wrong result. The gate read the filesystem; the merchant reads the network.
The loader, in full
The shipped loader is small enough to read in one sitting, which is the point:
const host = document.createElement('div');
host.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:2147483000';
const root = host.attachShadow({ mode: 'open' });
document.body.appendChild(host);
let loaded = false;
const load = () => {
if (loaded) return;
loaded = true;
const s = document.createElement('script');
s.src = '/widget.js';
s.defer = true;
document.head.appendChild(s);
};
setTimeout(load, 3000);
['pointerdown', 'keydown', 'scroll'].forEach((e) =>
window.addEventListener(e, load, { once: true, passive: true })
);
Three seconds, or first interaction, whichever comes first. The Shadow DOM host is what keeps a merchant's theme from reaching into my markup.
The trade-offs, including the one that costs me data
Lazy load after 3 seconds instead of rendering immediately. I gave up the chat bubble being present for the first three seconds of a visit. Some fraction of visitors bounce before the widget exists, and I cannot tell you what that fraction is — because a visitor who leaves before the loader fetches the widget never loads the widget, so they never appear in widget analytics. My own instrumentation is blind to exactly the population I'm worried about. I accepted the blind spot because the alternative is a 17 KB third-party script competing with the merchant's LCP on every single page view, and I'd rather lose a few sessions than degrade every storefront I'm installed on.
Shadow DOM instead of plain DOM. I gave up the ability for merchants to restyle the widget with their theme's CSS. That was a deliberate loss of a feature people ask for, because the alternative is a widget that inherits a theme's global button { width: 100% } and turns into a full-width bar. Isolation is worth more than customization when you're a guest on someone else's page.
One path instead of two. I gave up the fast rollback that a second code path quietly provides. There's no "flip back to the old widget" switch anymore, which means a bad widget release is now a real incident rather than a toggle. I'll take it: the second path was the reason a fix could be live for one group of merchants and missing for another, and I'd rather have one version of the truth than a safety net made of stale code.
And a check I should have written months earlier. The size gate now measures the file the loader actually fetches, and after each deploy I fetch the live URL and compare the hash to the build artifact. That last step is the one that matters: measuring a file on disk proves what I built, not what a merchant's browser receives.
The hash comparison is also what finally settled the two-file question. md5 of the file the theme snippet requested and md5 of the file the settings page requested were different, and both were "the widget" as far as my code was concerned. There is no version string to compare, no header to read — the only way to know which code a merchant is running is to ask the network, which is what I should have been doing from the first deploy.
Question
How do you verify that the artifact your performance budget measures is the artifact your users download? I've landed on "fetch the deployed URL in CI and assert on the hash," which catches stale deploys and wrong paths, but it doesn't catch a second code path serving an older file to a subset of users — that's how this whole thing stayed hidden. If you've solved that one, what does the assertion look like in your CI — or is "we only have one code path" the only answer anyone actually has?
Top comments (0)