How we built an analytics client that captures everything without ever blocking paint.
The Constraint
An analytics script that slows down the page is sabotage, especially on a marketing site where the conversion you are measuring is the thing you just hurt. So our hard rule: the client must never block page load, and it must never lose an event that happened before it finished loading. Those two goals fight each other, and the resolution is a stub and queue.
The Stub
A tiny synchronous stub defines the global immediately and queues every call into an array. This is all that runs before the real script arrives.
window.zv = window.zv || function () {
(zv.q = zv.q || []).push(arguments);
};
Now the page can call zv('event', {...}) from the very first line, and nothing is lost even though the real implementation is not loaded yet. Every call lands in zv.q.
Loading the Real Script Without Blocking
The real script loads async, so it never blocks parsing or paint.
<script>
window.zv = window.zv || function(){ (zv.q = zv.q || []).push(arguments) };
</script>
<script async src="https://cdn.example.com/zv.js"></script>
When zv.js arrives, it drains the queue in order before handling anything new:
const queued = (window.zv && window.zv.q) || [];
window.zv = realImplementation;
for (const args of queued) {
realImplementation.apply(null, args);
}
The stub and the real implementation share one entry point, so the page never knows or cares which one it is talking to.
Push the Work to the Edge
The browser stays thin on purpose. The heavy work runs on Cloudflare Workers:
- per site config (which features are on) is cached in Workers KV and read at the edge, so the script does not round trip to an origin to configure itself
- events batch in the browser and flush with
sendBeacon, which survives the page being closed
let buffer = [];
function enqueue(event) {
buffer.push(event);
if (buffer.length >= 10) flush();
}
function flush() {
if (!buffer.length) return;
navigator.sendBeacon('/ingest', JSON.stringify(buffer));
buffer = [];
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
The Byte Budget
The whole client sits around 3.1kb gzipped, treated as a hard ceiling. Every feature either fits or loads on demand. Session replay, for instance, is a separate module loaded only when enabled, so customers who never use it never download it.
What We Learned
- The stub and queue is the entire trick for never blocking and never losing events. It is a few lines and it solves both goals at once.
-
sendBeacononvisibilitychangeis what makes the last events survive navigation. A normal fetch on unload gets cancelled. - A hard byte budget forces good decisions. Without it, every feature quietly adds weight to the page you are paid to protect.
Honest Limit
Stub and queue slightly delays the first few events until the real script drains the queue. For analytics that is invisible. For anything user facing, it would not be acceptable.
If you ship a third party script, how do you keep it from becoming the slowest thing on your customers' pages?
I build Zenovay with my co-founder, cookieless website analytics on Cloudflare Workers. This is how our client script stays out of the way.
Top comments (1)
The stub-and-queue pattern is a good example of analytics respecting the product it measures. The tricky part is usually not capturing the first events; it is making replay idempotent so queued calls do not duplicate or reorder meaningfully. Measurement should never become the reason conversion got worse.