Analytics SDK Event Queue
Queue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- javascript
- async
- sdk
- interview
track('signup_click') then location.href = '/next'. The fetch is killed mid-flight. Or someone Promise.alls forty events, timestamps reorder, and a single 500 stalls the batch. An analytics SDK event queue is: enqueue without waiting, send in series, retry failures with a cap, and flush when the page hides so the last click has a chance to leave the tab.
This article uses a fake send(). In production you would often switch the hide path to navigator.sendBeacon. That is a note at the end, not the implementation.
Key takeaways
enqueuepushes and kicksflush. The click handler does notawaitthe network.- Flush is series: one in-flight
sendat a time, queue order preserved.- Retry the head with a bounded attempt count, then drop it so a poison event cannot stall the SDK.
pagehideandvisibilitychange(hidden) both callflush.sendBeaconis the production unload transport. The interview still implementssend.
Queue events, send in series, retry the ones that fail
Parallel send looks faster and then a 429 fan-out plus out-of-order event_n vs event_n+1 shows up in the warehouse. Series is the point: the next event starts when the current one fulfills or is dropped after the retry cap. That is the same contract as async series, with a queue that can grow while a flush is already running.
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createAnalytics({
send,
maxAttempts = 3,
backoffMs = (attempt) => 50 * 2 ** attempt,
} = {}) {
if (typeof send !== 'function') {
throw new TypeError('createAnalytics requires send(event)');
}
const queue = [];
let running = null;
function enqueue(event) {
queue.push({ event, attempts: 0 });
return flush();
}
async function sendWithRetry(item) {
let lastError;
while (item.attempts < maxAttempts) {
item.attempts += 1;
try {
await send(item.event);
return true;
} catch (err) {
lastError = err;
if (item.attempts >= maxAttempts) break;
await wait(backoffMs(item.attempts - 1));
}
}
console.warn('analytics drop', item.event.type, lastError);
return false;
}
function flush() {
if (running) return running;
running = (async () => {
while (queue.length > 0) {
const item = queue[0];
await sendWithRetry(item);
queue.shift();
}
})().finally(() => {
running = null;
if (queue.length > 0) flush();
});
return running;
}
function onHide() {
flush();
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') onHide();
});
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', onHide);
}
return { enqueue, flush, get size() { return queue.length; } };
}
enqueue always pushes then flush. If a flush is already running, running is reused and the while (queue.length) loop picks up the new tail. Do not start a second loop: that is parallel by accident.
shift happens after success or after the cap. Leaving a failed item at the head without a cap means one 500ed event blocks every later click. Dropping after maxAttempts is the interview policy. A dead-letter array is extra credit.
Fake send
const delivered = [];
async function fakeSend(event) {
await wait(10);
if (event.failOnce && !event._failed) {
event._failed = true;
throw new Error('network');
}
delivered.push(event.type);
}
const sdk = createAnalytics({ send: fakeSend, maxAttempts: 3 });
await sdk.enqueue({ type: 'view' });
await sdk.enqueue({ type: 'click', failOnce: true });
delivered; // ['view', 'click'] after the click event retries once
Keep send injected. Tests should not hit the network. A real send might be fetch('/collect', { method: 'POST', body: JSON.stringify(event), keepalive: true }). keepalive helps a little on unload. It is still not sendBeacon.
Hide and visibility
The last click is the one that dies on navigation. Listen to both:
| Event | When | Why you care |
|---|---|---|
visibilitychange + hidden |
tab backgrounded, mobile app switch | flush while the document can still fetch |
pagehide |
navigation, reload, bfcache enter | more reliable than unload on mobile |
Do not use beforeunload for analytics: it is for unsaved-form prompts, it is ignored a lot, and it fights back/forward cache. unload is worse.
flush on hide is best-effort. The JS task may still be killed. That is why production often uses sendBeacon on the hide path only, and the fake send in the article stays for the happy path and for interviews that want retry logic you can await.
Production note: sendBeacon
navigator.sendBeacon(url, blob) queues a POST the browser tries to finish after the page is gone. You do not get a response. You cannot set arbitrary headers. Payloads have a size cap (order of tens of kilobytes; treat it as small). Failed beacon returns false; there is no retry hook. So: beacon on hide for the remaining queue (maybe concatenated), retrying fetch/send while the page is visible. Do not implement the SDK’s send as beacon-only or you lose retry and status codes.
Series vs limited parallelism: a collector that is healthy can take a small pipeline (2 or 3). The interview default is series. Retry policy matches retry N times: send is a factory per attempt (same payload, new request). Do not retry an already-rejected Promise object.
A circuit breaker around send is a production extra when the collector is down: fail fast, drop or spill to localStorage, probe later. Out of scope for the queue itself.
Procedure
enqueuerecords{ event, attempts: 0 }and callsflush.flushis a single worker. While the queue is non-empty, retry the head up tomaxAttempts.shiftafter success or drop. Never block the tail on a dead head.- Bind
pagehideand hiddenvisibilitychangetoflush. - Inject
send. MentionsendBeaconfor unload, do not pretend you handled its opaque result.
Interview answer (out loud)
I enqueue objects and flush them in series so order holds and I do not stampede the collector. Each head is retried up to a cap, then dropped so one failure cannot stall the SDK. enqueue does not wait in the click handler; flush returns the in-flight promise if any. I listen to pagehide and visibilitychange hidden. send is injected; in the article it is fake. In production I would consider sendBeacon on hide only, because fetch may be killed and beacon has no retry.
Further reading
Related on this site
- Run async tasks in series
- Retry a promise N times
- Circuit breaker in JavaScript
- Fetch API fundamentals
- The event loop
- Polling metrics dashboard
Related guides
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.
- Circuit Breaker in JavaScriptClosed, open, half-open: stop calling a failing function, fail fast while open, and probe once when the cooldown expires.