ESC

Type to search the knowledge base.

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.

advanced5 min read
  • 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

  • enqueue pushes and kicks flush. The click handler does not await the network.
  • Flush is series: one in-flight send at a time, queue order preserved.
  • Retry the head with a bounded attempt count, then drop it so a poison event cannot stall the SDK.
  • pagehide and visibilitychange (hidden) both call flush.
  • sendBeacon is the production unload transport. The interview still implements send.

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

  1. enqueue records { event, attempts: 0 } and calls flush.
  2. flush is a single worker. While the queue is non-empty, retry the head up to maxAttempts.
  3. shift after success or drop. Never block the tail on a dead head.
  4. Bind pagehide and hidden visibilitychange to flush.
  5. Inject send. Mention sendBeacon for 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 guides