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.
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:
Output
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:
// 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: truevia 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.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.