Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

10.1 Real-world patterns: the analytics SDK

Batching, lifecycle flushes, bfcache-tagged restores, stable A/B assignment, and IndexedDB persistence with save-before-clear ordering — one vanilla SDK.

Levels: Advanced ~18 min read

Advanced

Everything in the series so far was a component; this page assembles the machine. A telemetry SDK has to batch (one pool slot per flush), flush at the lifecycle's real deadline, count bfcache restores as pageviews without double-counting, keep A/B assignment stable across reloads and restores, and survive quota saturation without losing events. The capstone recipe below is that SDK, in under a hundred lines of vanilla JavaScript — and it runs, driving the state machine exactly as a browser would:

Interactive Runnable example
Edit

Ready to run.

Reading the output as a session

The demo's console is a compressed user session. The initial pageview and interaction events buffer silently. The first hidden transition flushes them as one batched beacon — the spec's MUST moment, arriving right on schedule. Heavy traffic then saturates the mocked pool mid-burst, and the refused batch lands in overflow instead of vanishing. The next visible transition drains it — the pool refilled while the page was away. A pageshow with persisted fires the restore path: a fresh pageview tagged restored: true, so downstream dashboards can count returns separately from first visits. And the A/B bucket, read from localStorage with a deterministic fallback when storage is unavailable (as in this sandbox's opaque origin), rides along on every event — stable across reloads, restores, and saturation.

Surviving the closed tab

In-memory overflow dies with the page — including at the hard-kill scenario where no event fires at all. The production answer is IndexedDB: the only web storage that is asynchronous, transactional, and durable across dismissals and crashes. The extension is small but its failure-ordering rule is not: save before clearing. A queue that clears its buffer before the save commits loses data when the write throws; one that saves first and clears after at worst duplicates — and a duplicate is recoverable server-side (deduplicate by event ID), while a loss is not. The recipe below ships that order, plus the drain-on-launch that turns yesterday's stragglers into today's first beacons:

js overflow-idb.js
// Surviving a closed tab: persisting overflow to IndexedDB.
//
// In-memory overflow dies with the page. IndexedDB is the only
// web storage that is asynchronous, transactional, and survives
// crashes and dismissals — so a production queue mirrors every
// overflow batch there, and drains the store on the next launch.
//
// IndexedDB cannot run inside this preview (the sandboxed frame has
// an opaque origin, and storage requires a real one) — this file is
// tested outside the sandbox and shown here as the shipping recipe.

const STORE = 'beacon-overflow';

function openOverflowDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('beacon-queue', 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'queuedAt' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function saveOverflow(db, payload) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    // Clear the buffer FIRST is wrong — save BEFORE clearing, so a
    // throw between "saved" and "cleared" duplicates instead of
    // losing data (a duplicate is recoverable; a loss is not):
    tx.objectStore(STORE).put({ queuedAt: Date.now(), payload });
    tx.oncomplete = () => resolve(true);
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });
}

async function loadOverflow(db) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readonly');
    const req = tx.objectStore(STORE).getAll();
    req.onsuccess = () => resolve(req.result || []);
    req.onerror = () => reject(req.error);
  });
}

async function clearBatch(db, queuedAt) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).delete(queuedAt);
    tx.oncomplete = () => resolve(true);
    tx.onerror = () => reject(tx.error);
  });
}

// The drain-on-launch pattern that makes the store useful:
async function drainOnLaunch(db, url) {
  const batches = await loadOverflow(db);
  let recovered = 0;
  for (const batch of batches) {
    const queued = navigator.sendBeacon(url, batch.payload);
    if (queued) {
      await clearBatch(db, batch.queuedAt);
      recovered++;
    }
    // Not queued? Keep it in the store; the pool was full again.
  }
  return recovered;
}

// -- The failure-ordering rule this recipe encodes ----------------
//   1. save to IndexedDB     (durable)
//   2. clear the memory      (only after the save committed)
//   3. send                  (best effort)
//   4. on failure: keep it   (in memory AND the store)
// A queue that clears before saving loses data on the throw; a
// queue that never clears duplicates. This order is the safe one.
//
// Usage on a real origin:
//   const db = await openOverflowDb();
//   const q = new BeaconQueue(url, sendWithPersistence(db));
//   drainOnLaunch(db, url); // recover yesterday's stragglers

IndexedDB cannot run inside this page's sandbox — storage requires a real origin, and the sandboxed frame deliberately has none — so this file is verified outside the sandbox instead: the series' test suite opens the database, saves a batch, reads it back intact, and drains it through a route-intercepted sendBeacon on a real origin, end to end. The same honesty applies to your own environments: test persistence where storage exists, not where it is stubbed.

Quick check

The SDK's overflow batch was saved to IndexedDB, then the page was killed before the send. What happens on next launch?

// next page load

Key takeaways

  • One SDK, five behaviors: batching, lifecycle flush, bfcache-tagged restores, stable A/B, overflow drain.
  • The hidden transition is the flush deadline; the visible transition is the drain opportunity.
  • Tag restores with restored: true via persisted pageshow — count returns without inflating first visits.
  • Persist overflow to IndexedDB before clearing memory: duplicates are recoverable, losses are not.
  • Make every failure degrade toward duplication, then deduplicate server-side by event ID.
Was this page helpful?

Mark this learning resource complete to track your learning progress.