Tags: analytics concept

Event Batching and Delivery

Date: 2026-08-16


Events are queued in the browser and sent in batches. Everything still in the queue when the tab closes is gone — and the tab closes most often at exactly the moment you cared about.


What it is

Batching is holding events in memory and sending them together rather than one request per event. Delivery is the mechanism that gets them out, and whether it survives the page going away.

event fired ──▶ queue ──▶ [flush trigger] ──▶ network ──▶ vendor
                  │
                  └── tab closes here → events lost

Flush triggers are typically a batch size, a timer, or the page becoming hidden. Whichever comes first.

Why the last events are the ones you lose

The queue drains on a schedule. A user who clicks an outbound link, closes the tab, or navigates away leaves whatever’s queued behind.

That loss isn’t random. It concentrates on:

  • The last event of every session — the exit, which is often the most interesting one
  • Outbound link clicks, where navigation begins immediately
  • Form submissions, where the page unloads on submit
  • Bounces, where the whole visit is shorter than the flush interval
  • Slow connections, where the request doesn’t complete before teardown

In plain terms: the events most likely to go missing are the ones describing how a visit ended, which biases every exit and drop-off analysis towards the sessions that didn’t end abruptly.

sendBeacon

The mechanism that fixes most of it:

navigator.sendBeacon('/collect', JSON.stringify(events));

Hands the request to the browser, which delivers it even after the page is destroyed. Fire-and-forget: no response, no retry, no error handling. That’s the trade — you can’t know it arrived, but it will almost certainly leave.

Modern alternative with more control:

fetch('/collect', { method: 'POST', body: payload, keepalive: true });

keepalive gives the same survive-unload property while still returning a promise. Payload size is capped, so it’s for small batches rather than bulk [CHECK: the limit and whether it applies per-request or across all in-flight keepalive requests — verify against the Fetch specification, not a summary].

Flushing at the right moment

The event to bind to is visibilitychange, not unload:

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

unload and beforeunload are unreliable on mobile — a user switching apps or locking the phone may never fire them, and browsers increasingly ignore them because they break back/forward caching. visibilitychange fires in all of those cases.

Batch size and interval

The tension: larger batches mean fewer requests and more loss when the page goes; smaller batches mean more requests and less loss.

  • Interface events — batch freely. Losing a scroll event costs nothing
  • Funnel events — smaller batches, flush on visibility change
  • Transactions — don’t batch at all, and don’t rely on the browser. Send from the server, where the order actually exists — Client-Side vs Server-Side Tracking

That last line is the real answer to delivery loss for anything that matters commercially.

Retries and duplicates

Any retry introduces the possibility of the same event arriving twice — the original succeeded and the acknowledgement was lost. So retrying requires an event ID generated at fire time and deduplication downstream. See Idempotency and Deduplication.

Without an ID, a retry policy trades one silent error for another.

What to check

  • Which transport does your SDK use on unload? If it’s a plain fetch without keepalive, you’re losing exit events
  • Compare event counts to pageviews by device. Mobile losing proportionally more is the signature
  • Test on a throttled connection — the loss is invisible on a fast one
  • Look at last-event-in-session distributions. If they’re dominated by one or two event types, the others are being dropped at teardown

Related: Ad Blockers and Tracking Loss for the other systematic loss, and Instrumentation Debugging for seeing what actually leaves the browser.