Engineering

How the Pug SDK delivers events reliably

Analytics is only as good as the events that actually arrive. Capturing them is the easy part — getting them to the server through page unloads, flaky networks, and traffic spikes is where SDKs earn trust. Here’s how Pug’s does it.

Most analytics integrations capture plenty of events. The ones you can trust are the ones where those events reliably arrive — even when a user closes the tab mid-action, the network drops for a second, or a launch sends a spike of traffic. That last mile is invisible when it works and corrosive when it doesn’t: silently missing conversions make every report a little bit wrong. Here’s the delivery machinery inside the Pug Web SDK, and the failure modes each part is there to handle.

Batching: fewer requests, less overhead

Sending one HTTP request per event is wasteful — it hammers the network, drains battery on mobile, and adds latency. The SDK buffers events and flushes a batch when either trigger fires: the batch reaches its size limit (10 events by default) or a timer expires (5 seconds by default). Both are configurable. The result is a steady trickle of small batches instead of a storm of single requests — and you never write a line of it.

The page-unload problem, and sendBeacon

The hardest events to deliver are the ones fired right before the page goes away — the click that navigates, the action just before someone closes the tab. A normal fetch started at that moment is often cancelled by the browser. So on visibilitychange and pagehide, the SDK switches to navigator.sendBeacon, which the browser is specifically designed to deliver even as the page unloads. The batch is sent as binary protobuf, so it’s compact and fast. That’s the difference between capturing a user’s last action and losing it.

A queue that survives reloads

Buffered events don’t live only in memory. The queue is backed by localStorage (with an in-memory fallback if storage is unavailable), with debounced writes to stay cheap. If a tab closes or the browser crashes before a batch is sent, those events are still in the queue on the next page load — they’re picked up and delivered rather than lost. Reliability shouldn’t depend on the user keeping your tab open.

Lock, commit, rollback

When a batch is sent, the queue uses a two-phase protocol so events are never dropped or double-counted on failure. Sending locks (reserves) the events being flushed; a successful send commits, removing them; a failure rolls back, returning them to the queue for a later attempt. An in-flight batch can’t silently vanish, and a retry can’t duplicate what already succeeded.

Retries that know when to stop

Not every failure deserves a retry. The SDK classifies errors: transient ones (timeouts and retryable server responses) roll back and try again, while permanent ones (a malformed or rejected request) are not retried — retrying them would only flood your backend with requests that can never succeed. For the events you can’t afford to delay, an immediate flag attempts a direct send right away and falls back to the queue if the network is briefly down:

track('purchase', { amount: 49, currency: 'USD' }, { immediate: true })

Never throws, never blocks

Analytics should never be the reason your app breaks. track() is fire-and-forget and never throws — every send is wrapped, and when something can’t be sent it’s logged with the property at fault, not raised into your code. Events are also validated against their schemas before they leave the browser, so a malformed event is dropped with a clear log instead of quietly polluting your dashboard. Each autocapture listener is isolated in its own try/catch, so one misbehaving page element can’t take down the rest of your tracking.

A clean lifecycle

The transport moves through a small, predictable lifecycle — idle, flushing, destroyed. Calling destroy() on a single-page-app route change tears everything down cleanly: an in-flight batch is allowed to finish, no further flushes are scheduled, and the SDK can be re-initialized fresh. No leaked timers, no orphaned listeners, no duplicate sends.

Reliable, and yours

All of this runs against a backend you can own. Pug is open source and self-hostable — one Go binary, your events on your own servers — so reliable delivery doesn’t mean handing your data to someone else’s cloud. See the SDKs to get started, the Web SDK overview for the full feature set, and the platform page for how events flow from the SDK to insights.

FAQ

Common questions

Do analytics SDKs lose events when the page closes?

Many do — events fired just before a navigation or tab close get cut off mid-request. Pug switches to the browser’s sendBeacon on pagehide and visibilitychange, which is designed to deliver a final payload reliably as the page unloads, so the last batch still lands.

What is event batching?

Batching buffers events and sends them together once a size threshold or a timer is reached, instead of one network request per event. It means fewer round-trips, lower overhead, and less battery and bandwidth use — without you managing any of it.

What happens to my events if the network fails?

Transient failures (timeouts, retryable server responses) roll the batch back into the queue and retry. The queue is persisted to localStorage, so a dropped connection, a refresh, or a closed tab doesn’t discard buffered events. Permanent errors, like a malformed request, are not retried so the SDK never hammers your server.

Can the SDK slow down or crash my app?

No. track() is fire-and-forget and never throws — every send is wrapped so a tracking issue can’t surface as an app error. Each autocapture listener is isolated, and if localStorage is unavailable the SDK falls back to an in-memory queue.

A reliable SDK you can also own.

Open source, self-hostable on one Go binary, and free during open beta. Your events, delivered reliably to your own backend.