If you embed a third-party script, you are spending your users' bandwidth on someone else's judgment. We had not been careful with ours.
The BugDump widget shipped as a single 450 KB file — 117 KB gzipped — to every visitor of every page that embedded it. Anonymous readers of a marketing page paid it. People who never noticed the button paid it. That is a lot to ask for a feature most visitors never touch.
So we measured what was actually in there.
The measurement
esbuild will tell you exactly where a bundle's bytes went if you ask for a metafile. Ours:
Minified bytes | Share | |
|---|---|---|
| 248,965 | 55.5% |
Our own code | 117,162 | 26.1% |
| 75,064 | 16.7% |
| 7,077 | 1.6% |
Seventy-two percent of the bundle was two libraries, and both belonged to features that only run when someone does something specific. html2canvas renders a screenshot of the page. rrweb records session replay. Neither has any work to do while a visitor reads a blog post.
The obvious idea is to offer two builds — a "full" one and a "lite" one without the heavy features. We rejected it, for a reason worth stating plainly: that is a build-time answer to a runtime question. html2canvas is needed the moment a user clicks "Screenshot," which no build flag can predict. A customer on the full build would still pay 249 KB on every page load for a button most of their visitors never press. And a customer on the lite build whose plan includes session replay would get a feature that silently does nothing, with no way for the code to detect the mismatch.
The better answer is to keep one build and change when things load.
Splitting a build that cannot be split
Here is where it got interesting. Our script-tag build is an IIFE — a single self-executing bundle, the format you need when there is no module loader on the page. esbuild only supports code splitting for ESM output. splitting: true does nothing for IIFE.
But splitting and dynamic import are different things. A dynamic import() works fine inside a classic script; it is only import/export statements that require type="module". The catch is that esbuild resolves what it can at build time:
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');
// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);
The specifier has to be a runtime value. That is the whole trick, and it is why nobody notices the option exists — everyone writes the literal version, watches the module get inlined, and concludes IIFE cannot split.
Which leaves one question: at runtime, what URL do you import from? A third-party script does not know where it was served from — unless it asks:
const BASE = document.currentScript.src; // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);
document.currentScript is the piece to be careful with. It is only set while the script's top-level code is running. Read it inside a click handler and you get null. So it gets captured once, at module scope, into a constant — and everything downstream uses that.
The result, from the same three-file build:
File | Gzipped | Loads when |
|---|---|---|
Entry | 28 KB | Every page |
Screenshot chunk | 62 KB | Someone takes a DOM screenshot |
Replay chunk | 27 KB | The plan allows session replay |
The entry went from 117 KB to 28 KB gzipped. Nothing was removed.
The bug the split exposed
While wiring up the replay chunk we found something that had been quietly wrong.
Session replay is a paid feature. The old code started the recorder immediately at init, then asked our API whether the project's plan included replay, and called stop() if it did not. Functionally correct. But it meant every customer without session replay downloaded 82 KB of rrweb, started a DOM recorder, and then turned it off.
Making the download conditional on that same answer fixed both problems at once. The chunk is now requested only after the server says yes.
There is a real trade-off: replay begins one round trip later than it used to. We took it, because the recorder keeps a rolling window with periodic full snapshots — the first few hundred milliseconds of DOM mutations are not what a bug report leans on. The console, network, and action collectors are cheap, stay in the entry, and still cover from the first moment.
The defer thing we had wrong
We assumed the fix for a blocking script tag was defer. It is the standard advice. It is also, for this purpose, wrong.
We built a production-shaped page, throttled to 2 Mbps with 100 ms of latency, and measured the three variants. Median of three runs:
Script tag |
|
|
|---|---|---|
No attribute | 290 ms | 290 ms |
| 56 ms | 287 ms |
| 58 ms | 58 ms |
defer frees the parser — domInteractive collapses. But a deferred script is specified to run before DOMContentLoaded fires, so DCL still waits for the whole download. 287 ms versus 290 ms is not a win.
async frees both, because an async script is allowed to run whenever it arrives, with no ordering promise. That is exactly the right trade for a self-contained widget: nothing on the page needs it to have run at a particular moment.
Plenty of apps hang their own startup off DOMContentLoaded. If you embed a third-party script with defer and think you have made it non-blocking, you have moved the cost, not removed it.
Our snippet now reads:
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>
One caveat that comes with it: if you initialize manually from an inline script, async means the SDK may not exist yet. Wrap the call in a load listener — that event fires only after async scripts have executed.
What we would tell ourselves earlier
Measure before you theorize. We could have guessed for a week which parts were heavy. The metafile answered it in one command, and the answer — 72% in two optional libraries — pointed straight at the fix.
Prefer runtime decisions to build-time ones. Any time you find yourself designing a matrix of build variants, check whether the thing you are branching on is actually knowable at build time. Ours wasn't.
A feature flag that arrives after the download is not a feature flag. If the server already knows whether a customer is entitled to something, that answer should gate the bytes, not just the behavior.
Verify the advice, including your own. defer is good advice for a lot of scripts. It was not good advice for this one, and we only found out because we measured instead of trusting the received wisdom — ours included.
The new SDK is live. If you load it from bugdump.com, you already have it; add async to your tag when convenient. If you self-host the SDK, note that it is now three files rather than one and they must stay in the same directory — the widget configuration reference has the details.