Design a client-side analytics SDK
A small script that any website embeds to send product events to a collector, like Segment's analytics.js or Amplitude. The hard parts are a public API that works before the script loads, loading without hurting the host page, identity and sessions, a durable event queue with batching and retries, delivery when the tab closes, sampling, consent, and privacy.
The question
We're building a product analytics company. Customers paste a snippet into their websites and call things like
analytics.track("Order Completed", { total: 42 }). We store the events and show funnels and retention charts.Design the browser SDK. It will run on thousands of sites we don't control, from small blogs to a store with 50 million page views a day. It must never slow those sites down, and it must not lose the events that matter.
Real-life examples: Segment analytics.js, the snippet with a queue of calls made before it loads, Google Analytics 4 with gtag.js and its dataLayer, Amplitude and Mixpanel browser SDKs, PostHog, with autocapture and session replay, Plausible, a very small cookie-less script
Requirements exploration
An analytics SDK looks like the easiest problem in the list: collect an object, POST it to a server. A first version fits in ten lines. The real problem is where those ten lines run.
They run on someone else's page, next to code we've never seen, on phones with slow CPUs, in tabs that close without warning, in browsers that block trackers, and in countries with privacy laws. So the first minutes go to finding out which of those worlds we're in.
A few words first. An event is one fact about what a user did, such as Product Viewed or Signup Started. Each event has a name, properties (key and value pairs like price: 19), a timestamp, and who did it. The collector is the server endpoint that receives events, and the host page is the customer's website where our script runs.
Questions to ask first
- Is this a first-party SDK for our own app, or a product other companies embed? Embedded means we're a guest: we can't assume a framework, a bundler, or a build step, and every bug we ship breaks thousands of sites at once.
- What do customers track? Hand-written events only, or also automatic page views and clicks? Automatic capture changes the privacy story a lot.
- How much traffic? A large customer at 50 million page views a day with about 10 events per view sends 500 million events a day, around 6,000 per second on average and several times that at peak. Batching on the client is how we keep requests per second sane.
- How bad is a lost event? A lost
Button Hoveredis noise. A lostOrder Completedbreaks a revenue report. Do customers need a way to mark events as important? - Which regions and laws? In the EU and UK, analytics tracking generally needs consent first (GDPR and the ePrivacy rules). California users can opt out of the sale and sharing of their data (CCPA and CPRA). This decides whether we can send anything before a consent banner is answered.
- Which browsers? Safari's Intelligent Tracking Prevention (ITP) limits cookies and storage set by scripts. Brave and many extensions block known tracker domains. On some sites a large share of traffic sits behind one of these.
- Do customers want to install through npm too? A script tag and an npm package can share the same core but load differently.
Functional requirements
- A public API:
track(name, properties),page(),identify(userId, traits),group(groupId, traits),reset(), and a way to add plugins. - Calls made before the script finishes loading are kept and replayed in order.
- Every event carries an anonymous id, a user id after login, a session id, a unique event id, a timestamp, and page context (URL, referrer, screen size, locale).
- Events are batched, stored so they survive a reload, retried on failure, and delivered when the tab is hidden or closed.
- Consent is respected: nothing leaves the browser before the user agrees, where the law needs it.
- Optional automatic page views for single page apps and optional click autocapture.
- A debug mode that shows every event, its validation result, and the queue.
Non-functional requirements
| Requirement | Target | Why this number |
|---|---|---|
| Loader size | Under 1 KB gzipped, inline | It sits in every customer's HTML and blocks nothing |
| Full SDK size | Under 25 KB gzipped | Close to a medium image; about 100 ms of parse and compile on a mid-range phone |
Main thread work per track() call | Under 1 ms | A page may call it 20 times during a click handler; we must not add to INP |
| No long tasks | No task of ours over 50 ms | 50 ms is the long task line; long tasks delay input |
| Delivery rate | Above 99% for normal events, above 99.9% for key events | Lost events are silent; no one notices until a report is wrong |
| Latency to dashboard | Seconds to a minute is fine | Analytics is not real time; this lets us batch |
| Network | One request per 10 to 30 events | Each request costs radio power on a phone and a connection on the collector |
Out of scope
- The ingestion pipeline, warehouse, and dashboards. We design up to the collector's HTTP contract.
- Session replay (recording the DOM). It's a separate, much heavier product.
- Performance metrics such as LCP and INP. That is real user monitoring; see the Real-user monitoring (RUM) lesson. The same transport can carry them.
- Error tracking with stack traces; see the Error tracking lesson.
- Building the consent banner itself. We read consent from it; the
cookie-consentproblem designs the banner.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes this hard
Five traits of this problem drive every later chapter. Name them early in the interview: it shows you see past the fetch call.
- We're a guest on someone else's page. The customer's page owns the main thread, the global scope, and the user's patience. Our script competes with their checkout code for the same CPU. If we throw an error, block rendering, or add a 300 ms task to a click, it's their Core Web Vitals and their revenue that suffer. And a bug we ship lands on thousands of sites at the same moment.
- It's fire and forget, with no one watching. A broken button gets a bug report. A dropped event gets nothing: the chart just looks a little lower. So the SDK has to be correct by design, and it needs a debug mode because no user will ever tell us.
- Pages die without notice. On mobile, users switch apps, and the browser may freeze or kill the tab later without running any more of our code. The last events of a visit, often the most important ones like
Checkout Completedbefore a redirect, happen exactly when the page is going away. - The environment pushes back. Ad blockers block known analytics domains. Safari limits how long script-set cookies and storage live. Networks drop. Some users browse in private mode where storage is thrown away at the end of the session.
- Privacy is a legal requirement, not a feature. In many places we can't set an id or send an event until the user agrees. Collecting an email address by accident in a URL or a button label is a data breach, not a bug.
Notice what is missing: no complex UI, no real-time updates, no rendering. The SDK's only visible parts are a debug panel and, sometimes, the consent banner it listens to. The wireframe below shows where our code lives on a typical host page.
Buy now runs the customer's handler, which calls track().Think first
The two rules, "do no harm" and "don't lose events", pull against each other. Where do they clash?
Saving every event to storage right away protects it, but localStorage writes are synchronous and block the main thread. Sending each event at once protects it too, but costs a request per event. Retrying forever protects data, but burns battery and fills storage on a phone that stays offline.
The design has to find the middle: batch in memory, persist in cheap chunks during idle time, retry with limits, and flush once at the moment the page is hidden. The rest of the guide is mostly about where to draw those lines.
The public API and the snippet
Customers meet the SDK through two things: the snippet they paste into their HTML and the methods they call. Both are a contract we can never break, because thousands of copies of today's snippet will still be live in five years.
Four verbs are enough
| Method | What it records | Example |
|---|---|---|
track(name, props) | Something the user did | track("Plan Upgraded", { plan: "pro" }) |
page(name?, props?) | A page or screen view, with URL and referrer filled in | page("Pricing") |
identify(userId, traits) | Who the user is, after login or signup | identify("u_812", { plan: "pro" }) |
group(groupId, traits) | Which account or company the user belongs to (for B2B products) | group("acme", { seats: 40 }) |
Keep the surface this small on purpose. Every method we add must be stubbed in the snippet, documented, and supported forever. Segment and RudderStack use almost exactly these verbs, which is also a gift to customers: switching vendors means changing one script tag.
Calls made before the SDK loads
Here's the naive install: a script tag pointing at our CDN, and the customer's code calling analytics.track(). If the script tag is async (and it must be, see the next chapter), the customer's code usually runs first. analytics is undefined, the call throws a ReferenceError, and on a badly written page that error can stop the rest of their script. We just broke their site.
Decision How should the page call the SDK before the SDK has loaded?
What we pick: Ship the stub queue snippet as the main install, and an npm package that wraps the same core for teams with a bundler. This is the pattern Google Analytics uses with dataLayer and Segment uses with its analytics array: it lets the page call us immediately while our code arrives whenever the network allows.
(function (w, d, src) {
var a = (w.analytics = w.analytics || []);
if (a.initialized || a.invoked) return; // real SDK, or snippet pasted twice
a.invoked = true;
a.snippetVersion = 3;
["track", "page", "identify", "group", "reset", "use", "consent"]
.forEach(function (m) {
a[m] = function () {
var args = Array.prototype.slice.call(arguments);
a.push([m, Date.now()].concat(args)); // keep the call time
return a;
};
});
var s = d.createElement("script");
s.async = true;
s.src = src;
d.head.appendChild(s);
a.page(); // inside the guard, so a doubled snippet counts one view
})(window, document, "https://cdn.example-analytics.com/v1/sdk.js");
Three details matter. First, the stub stores Date.now() with each call: if the SDK arrives 2 seconds later, Signup Started still gets the moment the user clicked, not the moment we caught up. Second, snippetVersion tells the SDK which snippet a site runs, so we know when an old copy still needs support.
Third, the guard on invoked makes a doubled snippet (common when a tag manager also injects it) harmless. That only works because the first page() call sits inside the guard too; a page() after the closing line would run twice and count every view twice. Segment's snippet uses the same guard and logs a console error when it's pasted twice. Google's version is even smaller: gtag() is a function that pushes its arguments onto the window.dataLayer array, which gtag.js works through once it loads.
// Inside sdk.js, once the core is ready
const stub = (window as any).analytics as unknown[][];
const sdk = createAnalytics(readConfig());
// Replay until the array is empty. A call made during the replay (by a plugin
// or a customer callback) still hits a stub, lands at the end, and runs in turn.
while (stub.length > 0) {
const [method, time, ...args] = stub.shift()!;
sdk.callAt(method as string, time as number, args); // same order as called
}
sdk.initialized = true;
(window as any).analytics = sdk; // from now on, calls reach real methods
Plugins: one pipeline for every event
Customers always want to change events: drop internal staff traffic, rename a property, add the A/B test variant, strip a query parameter. Instead of a growing list of options, run every event through an ordered pipeline of small functions. Each plugin gets the event and returns it changed, returns it as is, or returns null to drop it.
type Plugin = {
name: string;
stage: "before" | "enrich" | "destination";
run(event: AnalyticsEvent): AnalyticsEvent | null;
};
function runPipeline(event: AnalyticsEvent, plugins: Plugin[]) {
let current: AnalyticsEvent | null = event;
for (const p of plugins) {
if (!current) return null;
try {
current = p.run(current);
} catch (err) {
debugLog("plugin failed, skipped", p.name, err); // never throw into the host page
}
}
return current;
}
before can drop events (consent, filters), enrich adds context, destination hands the event to the queue. A broken plugin is skipped, not fatal.Our own features are plugins too: page context, session tracking, consent filtering, and sampling each live in their own plugin. That keeps the core tiny and lets a customer replace one piece. Plugins run synchronously and must stay fast; anything slow (like hashing with crypto.subtle) belongs after the event is in the queue.
Typed events, checked in development only
The biggest data quality problem in analytics is drift: one team sends Order Completed with total, another sends order_completed with amount. The fix is a tracking plan: a list of allowed events and the type of each property, stored as JSON Schema. From it we generate TypeScript types so the compiler catches typos, and a validator that checks events at runtime.
// Generated from the tracking plan
type Events = {
"Order Completed": { orderId: string; total: number; currency: "USD" | "EUR" };
"Signup Started": { method: "email" | "google" };
};
export function track<K extends keyof Events>(name: K, props: Events[K]): void;
track("Order Completed", { orderId: "o_19", total: 42, currency: "USD" }); // ok
track("Order Completed", { total: "42" }); // compile error: wrong type, missing orderId
Runtime validation costs bytes: a JSON Schema validator plus the schema can be 10 to 30 KB. So it never ships in the production SDK. In debug mode (a cookie, a query parameter, or analytics.debug(true)) the SDK lazy loads sdk-debug.js, which validates every event and prints clear warnings. In production the collector validates instead and tags bad events, so a dashboard shows "12% of Order Completed events are missing currency" without adding a byte to users' downloads.
Check your understanding · Exercise 2
A customer's page runs analytics.track("Hero Clicked") 800 ms after load, but the SDK file arrives 2 seconds after load. What happens with our stub snippet? Select all that apply.
Loading without hurting the page
The Third-party scripts and supply chain lesson covers why outside scripts are the most common reason a fast site turns slow. This chapter applies it to ourselves: we are the third party. The goal is that a customer who adds our snippet sees no change in LCP (Largest Contentful Paint, good under 2.5 s) and INP (Interaction to Next Paint, good under 200 ms).
How the file arrives
A script added by another script, like ours, is async by default: it downloads in parallel with parsing and runs as soon as it arrives, without blocking the HTML parser. We can also set fetchPriority = "low" on the script element (current Chrome, Safari, and Firefox all support the hint), so our download never competes with the hero image that decides LCP. The defer attribute would also work for a hand-written tag, but it waits for the whole document to parse, which delays page views on long pages.
A small core and lazy extras
Every byte we ship is downloaded, parsed, and compiled on every page view of every customer. On a mid-range Android phone, JavaScript parse and compile run at very roughly 1 MB per second of uncompressed code, so 100 KB of uncompressed SDK costs around 100 ms of main thread before it does anything. Splitting the SDK keeps the common path small.
| Piece | Loaded when | Budget (gzipped) |
|---|---|---|
| Inline snippet (stubs + loader) | With the HTML | Under 1 KB |
| Core: queue, transport, identity, session, consent gate, page context | Always, async | 12 to 18 KB |
| Autocapture (clicks, forms) | Only if enabled in the remote config | About 5 KB |
| Debug tools and schema validator | Only in debug mode | 20 to 30 KB, never seen by real users |
| Destination plugins (send to other tools) | Only those the customer turned on | Varies |
For scale, products differ a lot. Plausible advertises a script of about 1 KB because it mostly sends page views, while a Google tag with several products configured commonly transfers tens of KB or more. Full-featured SDKs such as PostHog's, with autocapture, replay, and surveys, load much more but split it into lazily loaded parts.
A budget of under 25 KB gzipped for the core puts us closer to the small end while keeping batching, retries, and identity. A size check in CI fails the build if a change crosses it (the Performance budgets and monitoring lesson shows how).
Never a long task
A long task is any main thread task over 50 ms. While it runs, the browser can't respond to a tap. The trap for an analytics SDK is subtle: customers call track() inside their click handlers, so whatever we do synchronously inside track() becomes part of their interaction, and so part of their INP.
If track() serializes JSON, writes localStorage, and builds a request, a click can cost 5 to 20 ms more on a slow phone, 20 times a page. On a page already close to the 200 ms INP line, that is enough to push it over.
Decision Where should the SDK do its work after track() is called?
What we pick: Capture now, process in idle time. Our per-event work is tiny once it's out of the click handler, so a worker isn't worth its startup cost. The one thing track() must do synchronously is capture the arguments, because the customer may mutate the object right after the call.
function scheduleBackground(work: () => void) {
const s = (globalThis as any).scheduler;
if (s?.postTask) {
s.postTask(work, { priority: "background" });
} else if (typeof requestIdleCallback === "function") {
requestIdleCallback(work, { timeout: 2000 }); // run within 2 s even if never idle
} else {
setTimeout(work, 50);
}
}
function processPending() {
const start = performance.now();
while (pending.length > 0 && performance.now() - start < 8) {
enqueue(runPipeline(pending.shift()!, plugins)); // a few events per slice
}
if (pending.length > 0) scheduleBackground(processPending);
}
scheduler.postTask ships in Chromium and in Firefox since version 142. Safari ships neither it nor requestIdleCallback (the latter only behind a flag in previews), so the setTimeout fallback matters.track() only captures and returns.Check your understanding · Exercise 3
A customer reports that their INP got worse after adding the SDK. Which changes would help? Select all that apply.
Identity and sessions
Analytics is about people over time: a funnel needs to know that the visitor who viewed pricing on Monday is the one who paid on Thursday. So every event carries ids, and the SDK owns three of them.
- Anonymous id: a random id for this browser, created on the first visit with
crypto.randomUUID(). It exists before anyone logs in. That function only exists on HTTPS pages, so the SDK keeps a small fallback built oncrypto.getRandomValues()for the rare HTTP site. - User id: the customer's own id for a logged-in user, set by
identify(). It's the same on every device. - Session id: one visit. We use the session's start time in milliseconds, which is unique enough per browser and sorts naturally.
Where the anonymous id lives
Decision Where should the SDK keep the anonymous id?
What we pick: Use a cookie on the top domain as the main home, mirrored in localStorage as a backup if the cookie is cleared, and document the server-set cookie as an upgrade for customers who need longer life in Safari. Offer cookieless mode for customers who only want page counts. Whatever we pick, we only write any of it after consent where consent is needed (see Sampling, consent, and privacy).
How do we find the top domain? We can't just take the last two labels: for shop.co.uk that gives co.uk, a public suffix where browsers refuse cookies. The cheap trick most SDKs use is to try, from the shortest candidate upward, and keep the first one the browser accepts.
function topDomain(hostname: string): string {
const parts = hostname.split(".");
for (let i = parts.length - 2; i >= 0; i--) {
const candidate = parts.slice(i).join("."); // co.uk, then shop.co.uk
document.cookie = `__probe=1; Domain=${candidate}; Path=/; SameSite=Lax`;
if (document.cookie.includes("__probe=1")) {
document.cookie = `__probe=; Domain=${candidate}; Path=/; Max-Age=0`;
return candidate;
}
}
return hostname; // localhost or an IP address
}
Login, logout, and stitching
When a user logs in, the customer calls identify("u_812"). We keep the anonymous id and add the user id to every later event. That one event carrying both ids is what lets the warehouse stitch identities: all earlier anonymous events from this browser now belong to u_812. When the same user logs in on a phone, that phone's anonymous id is stitched to u_812 too.
Two traps. First, reset() on logout must clear the user id and create a new anonymous id. Otherwise, on a shared family laptop, the next person's browsing is stitched to the previous user.
Second, if identify() is called with a different user id than the stored one without a reset() in between, treat it as a new person: start a new anonymous id rather than merging two accounts. Merging two known ids on purpose, for example when a guest checkout becomes an account, is an explicit merge that the server handles, through a separate call like Segment's alias().
reset() on logout keeps the next person on a shared laptop out of this user's history.What a session is
A session ends after 30 minutes without any event. Google Analytics and Amplitude both default to 30 minutes, and customers will compare our numbers with theirs, so matching the convention matters more than the exact value. We keep sessionId and lastEventAt in localStorage so every tab of the same site shares one session. On each event: if now - lastEventAt is over 30 minutes, start a new session and emit a Session Started event first; then update lastEventAt.
Edge cases worth naming: a tab left open overnight sends its next event into a new session, which is right. Two tabs racing to start a session at the same moment can create two ids; that's acceptable because the warehouse can merge sessions that overlap in time for the same anonymous id. And a session never needs a timer: we only check when an event happens.
Check your understanding · Exercise 4
A store runs www.shop.com for browsing and pay.shop.com for checkout. Its funnel shows almost no one reaching checkout, yet sales are fine. What are likely causes or fixes? Select all that apply.
The event queue: batching, storage, and retries
The naive transport sends one fetch per event. A product page with a page view, three scroll milestones, a few impressions, and two clicks makes 10 requests in 30 seconds. On a phone, each request can wake the radio, and on the collector each one is a separate HTTP exchange.
For the large customer from the requirements, 6,000 events per second become 6,000 requests per second instead of a few hundred. And if the tab reloads while a request is waiting, the event is gone.
So events go into a queue, and the queue sends batches: one POST with many events. The queue answers four questions: when to send, where events wait, what to do on failure, and what to do when it's full.
When to send a batch
| Trigger | Our value | Reason |
|---|---|---|
| Count | 20 events | Big enough to cut requests by about 20 times, small enough that one failure doesn't hold back much |
| Size | 32 KB of JSON | Leaves room under the 64 KB budget that page-hide requests share (next chapter) |
| Time | 5 s after the first event waiting | Bounds delay on quiet pages that never reach 20 events |
| Page hidden | Always | The last chance we may get |
| Key event | Right away, with whatever is already waiting | Order Completed should not wait 5 s next to a redirect |
Where events wait
Decision Where should waiting events live so a reload or crash doesn't lose them?
What we pick: Keep a memory buffer for speed and persist it to an IndexedDB outbox in idle time, every second or so. Fall back to localStorage with a small cap where IndexedDB isn't usable. The IndexedDB and Client storage options lessons cover quotas and eviction. Note that Safari may also delete script-writable storage after 7 days of Safari use without the user interacting with the site, so a long offline queue is best effort.
Several tabs of the same site each run the SDK, and they share one IndexedDB. Each tab writes its own events tagged with a tab id and only sends its own. When a tab starts, it checks for events from tabs that closed without sending, and one tab adopts them, using a Web Locks lock (navigator.locks.request("outbox", ...)) so two tabs don't send the same orphaned batch at once. The event ids make a rare double send harmless anyway.
Failures, retries, and duplicates
- Retry on network errors,
429 Too Many Requests, and5xx. Wait with exponential backoff and full jitter: a random wait between 0 andmin(60 s, 1 s x 2 to the power of attempt). Honor aRetry-Afterheader when the collector sends one. The Retries, timeouts, and backoff lesson explains why jitter matters: without it, a collector outage ends with every browser retrying at the same second. - Don't retry a
400(the batch is malformed) or413(too large). Split a 413 batch in half and try each half once; drop a 400 batch and report it to our own error endpoint. - Deduplicate with an event id. Every event gets a
messageIdfromcrypto.randomUUID()whentrack()runs. If a batch reached the server but the response was lost, the retry sends the same ids, and the collector ignores ids it has seen in the last 24 hours. At-least-once delivery plus dedupe gives us effectively once. - Order by time, not arrival. Batches can arrive out of order after retries, so the warehouse sorts by the event's own timestamp. Phone clocks can be wrong by minutes, so each batch also carries
sentAt; the server computes the skew as its receive time minussentAtand shifts every event's timestamp by it.
messageId.When the queue is full
A phone offline for a week, or a page stuck in a loop calling track() 100 times a second, can grow the queue without bound. Cap it at 1,000 events or 2 MB, whichever comes first. When it's full, drop the oldest normal events first and keep key events. Never drop silently: count drops and send the count with the next batch (dropped: 312), so the dashboard can show a gap instead of a mysterious dip.
Also cap the rate: more than 50 events per second from one page is almost always a bug, so the SDK keeps a token bucket and drops the excess with a debug warning.
Try the queue below. Add events, watch the triggers fire, fail the network to see backoff, and hide the tab in the middle of a batch.
Check your understanding · Exercise 5
A batch of 20 events is sent. The collector stores it, but the phone loses signal before the 200 response arrives. What does a good design do? Select all that apply.
Delivery when the page goes away, and offline
The events at the end of a visit are often the valuable ones: the last scroll depth, the time spent, a Checkout Completed fired just before the page redirects to a thank-you page on another domain. The TikTok feed guide uses the same moment to send watch time; here we build it into the general queue.
Which page event to listen to
The old answer was unload or beforeunload. Both are unreliable on mobile: when a user switches apps, the browser may later freeze or discard the tab without firing either. Chrome has gone further and deprecated unload: since Chrome 155 (October 2026) its handlers no longer fire by default on any site.
The back/forward cache (bfcache) keeps a page in memory so the Back button restores it instantly. In Firefox, an unload listener makes a page ineligible for it, and Safari often caches the page anyway and skips the handler. So an SDK that adds an unload listener gains nothing reliable and can make the customer's Back button slower.
The modern answer: flush when visibilitychange fires with document.visibilityState === "hidden". It fires when the user switches tabs, switches apps, locks the phone, or navigates away, and on mobile it's the last event we can count on. Also listen to pagehide as a backup: it fires whenever the page is unloaded or put into the bfcache, so it covers any browser that skips visibilitychange on the way out. Flushing twice is harmless: the second time the buffer is empty.
How to send while the page is leaving
Decision Which API should send the final batch when the page is hidden?
What we pick: Use normal fetch while the page is visible (we want the response, to retry), and sendBeacon with a text/plain JSON body on hide, falling back to fetch with keepalive if sendBeacon is missing. Keep each beacon under about 60 KB and send at most what fits; anything left stays in the outbox for the next page view.
const BEACON_LIMIT = 60_000; // bytes, under the shared 64 KiB budget
const encoder = new TextEncoder();
function send(body: string): boolean {
if (typeof navigator.sendBeacon === "function") return navigator.sendBeacon(url, body);
fetch(url, { method: "POST", body, keepalive: true }).catch(() => {}); // old browsers
return true;
}
function flushOnHide() {
const batch: AnalyticsEvent[] = [];
let bytes = 200; // room for writeKey, sentAt, and the brackets
for (const event of buffer.peek()) { // oldest first
const size = encoder.encode(JSON.stringify(event)).length + 1; // bytes, not characters
if (bytes + size > BEACON_LIMIT) break; // the rest waits in the outbox
batch.push(event);
bytes += size;
}
if (batch.length === 0) return;
const body = JSON.stringify({ writeKey, sentAt: new Date().toISOString(), batch });
if (!send(body)) return; // refused: everything stays in the outbox
buffer.remove(batch); // so the pagehide that often follows finds nothing to resend
outbox.markSent(batch.map((e) => e.messageId)); // may not finish; ids dedupe a resend
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushOnHide();
});
addEventListener("pagehide", flushOnHide);
text/plain, a simple request with no preflight. Sizes are counted in UTF-8 bytes, because the budget is in bytes and one character can take up to 4. If the outbox update doesn't land before the tab dies, the next page view resends those events and the collector drops them by id.sendBeacon returns false, nothing is lost: the events are still in the outbox and go out on the next page view.Coming back from the back/forward cache
A page restored from bfcache doesn't load again; its JavaScript simply resumes. Listen for pageshow with event.persisted === true: restart the flush timer, check whether the session timed out while the page was frozen, and send a page view, because to the user it was a new view of the page. Also avoid things that block bfcache: no unload listener, and no open WebSocket held by the SDK.
Offline
When requests fail with network errors, events stay in the IndexedDB outbox and backoff slows our attempts. navigator.onLine === false reliably means no network at all, so pause sending then. true only means some network exists, maybe a captive portal, so keep relying on request results. On the online event, reset the backoff and flush right away.
We do not register a service worker or Background Sync: the customer owns the site's service worker scope, and a guest script must not claim it. The Offline sync engine guide goes deeper on durable queues for apps that own the whole page.
Check your understanding · Exercise 6
Which statements about the page-hide flush are true? Select all that apply.
Sampling, consent, and privacy
Two different questions decide whether an event leaves the browser. Sampling asks "do we need it?", a cost decision. Consent asks "are we allowed to?", a legal one. Both must be decided per user, consistently, and early in the pipeline.
Sampling without breaking funnels
A customer with 500 million events a day may only want to pay for 10%. The naive way is if (Math.random() < 0.1) send() per event. It cuts volume, but it destroys funnels: a user who viewed a product, added it to cart, and paid would have each step kept with a 10% chance, so only 0.1% of buyers show all three steps. The funnel shows a 99% drop that never happened.
Decision How should the SDK sample?
What we pick: Deterministic per user on the client, with the rate set by remote config so it can change without a release. Hash the user id when known and the anonymous id before login, and keep the decision fixed for the rest of the session so a login mid-visit doesn't cut a funnel in half. Each event carries sampleRate, so the server can scale counts back up: 1 sampled event at rate 0.1 stands for 10.
// FNV-1a: a tiny, fast, well-spread 32-bit hash (not for security)
function fnv1a(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
function inSample(id: string, rate: number, salt = "2026-q4"): boolean {
return fnv1a(salt + ":" + id) % 10_000 < rate * 10_000;
}
Rate limits sit next to sampling. The client-side token bucket from the queue chapter stops runaway loops. The server can also send back a lower rate in remote config during an incident, and answer 429 with Retry-After so every browser backs off together.
Consent before anything leaves the device
Under GDPR and the ePrivacy rules, sites in the EU generally need opt-in consent before storing ids on the device or tracking for analytics. In California, CCPA and CPRA give users the right to opt out of the sale or sharing of their data. This is a simplified summary, not legal advice: the rules differ by country and change over time (the UK, for example, has relaxed consent for some low-risk first-party analytics under its Data (Use and Access) Act 2025).
The SDK can't know the law for each visitor, so the customer (or a consent management platform, or CMP, the tool behind most cookie banners) tells us the state, per category: analytics and marketing. The customer's legal team decides the defaults; the SDK's job is to make every option easy and the safe one the default.
- Pending: the banner is showing. Events are created and kept in memory only. No cookie, no
localStorage, no IndexedDB, no network. Not even the anonymous id is written. - Granted: write the ids, flush the held events, and work normally from now on.
- Denied: drop the held events, write nothing. Optionally send fully anonymous, cookieless counts if the customer's legal team allows it.
- Changed later: if the user withdraws consent, stop sending, delete our cookies and storage, and call
reset().
Google's Consent Mode follows the same idea: the page calls gtag("consent", "default", ...) with categories such as analytics_storage and ad_storage set to denied, then update when the user answers. Version 2 added two more categories for ads, ad_user_data and ad_personalization. Our analytics.consent({ analytics: "granted" }) is the same shape, which makes it easy to wire both to one banner.
Consent Mode also shows the two ways to handle the Denied state. In its basic mode, Google's tags don't load at all until the user agrees. In its advanced mode, they load and send cookieless pings while consent is denied, which Google uses for modeling. Our Denied state matches basic mode by default, with cookieless counts as an opt-in for customers whose lawyers approve it.
Privacy by default
- Global Privacy Control (GPC): if
navigator.globalPrivacyControlistrue(the browser also sends theSec-GPC: 1header), the user has asked not to have their data sold or shared. Brave and DuckDuckGo send it by default, Firefox once the user turns it on, and some extensions add it; Chrome and Safari don't support it natively. California regulators treat it as a valid opt-out. Default: turn off marketing destinations; the customer decides about first-party analytics. - No personal data by default: strip query parameters that look like secrets (
email,token,password,code) from captured URLs, never capture input values, and flag property values that look like emails in debug mode. - IP addresses reach the collector with every request; the browser can't hide them. Truncating or dropping them happens on the server before storage, and the SDK should say so in its docs rather than pretend.
- Data residency: an EU endpoint (
eu.collector...) chosen in config, so EU data never leaves the region.
The lab below has 12 users. Move the sample rate and watch who is in: the same people stay in as the rate grows, because the hash doesn't change. Then change consent and GPC for the selected user and see what actually leaves the browser.
Check your understanding · Exercise 7
A customer samples at 20%. Which statements are true with deterministic per-user sampling? Select all that apply.
Automatic tracking and ad blockers
Customers love not writing tracking code. Two automatic features are worth building: page views in single page apps, and click autocapture. Both are optional plugins, loaded only when turned on in remote config.
Page views in single page apps
On a classic site, each page loads the SDK again and the snippet's page() call counts it. In a single page app (SPA), the router changes the URL with history.pushState() and no page load happens, so only the first view is counted. A dashboard that shows one page view per visit for a React app is a bug customers notice on day one.
Decision How should the SDK notice route changes in a single page app?
What we pick: Use the Navigation API when window.navigation exists, fall back to patching history, and let customers turn auto page views off and call page() themselves. In both cases, skip the event when only the hash or nothing changed, and wait one frame before reading document.title, because most routers update the title just after the URL.
function watchRoutes(onChange: () => void) {
let lastPath = location.pathname + location.search;
const check = () => {
const path = location.pathname + location.search;
if (path === lastPath) return; // hash-only or no change
lastPath = path;
requestAnimationFrame(() => onChange()); // let the router set the title
};
const nav = (window as any).navigation;
if (nav) return nav.addEventListener("currententrychange", check);
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method];
history[method] = function (this: History, ...args: any[]) {
const result = original.apply(this, args as any); // always call through
check();
return result;
};
}
addEventListener("popstate", check);
}
document.title first, so the page event has the right title. Comparing path and query stops hash changes and repeated pushes from counting as views.Click autocapture and its privacy risk
Autocapture adds one click listener on document in the capture phase, marked passive, and records what was clicked: the tag, a short text label, a few safe attributes like id and data-track, and a CSS path. Customers then name events later in the dashboard ("clicks on button.buy are Buy Clicked"), without a code release. Expect volume too: autocapture can produce several times more events than hand-written tracking, which is a cost question for the customer.
The risk is in the text. A button can say "Send to jane@mail.com", a list item can hold a patient's name, a link can carry a reset token in its URL. These rules keep autocapture safe:
- Never read
input,textarea, orselectvalues. - Skip elements inside
[data-private]or a selector the customer chooses. - Cut text to 50 characters, and drop it if it looks like an email, phone, or card number.
- Strip query strings from captured link URLs.
- Keep autocapture off by default on pages matching patterns like
/accountor/checkout.
Ad blockers and first-party proxies
Blocking lists like EasyPrivacy match known analytics hosts and paths. Depending on the audience, a noticeable share of events never arrive: often small on mainstream consumer sites and much larger on developer-heavy ones. The common workaround is a first-party proxy: the customer routes a path on their own domain, such as shop.com/ingest, to our collector through their CDN or server, and serves the SDK file from there too.
Be honest about what that is. It makes delivery reliable for users whose network or extension blocks third-party hosts by mistake. It also defeats a choice some users made on purpose.
Our position as a vendor: support the proxy, but tie it to the rules from the previous chapter, so consent, GPC, and opt-outs are respected exactly the same through the proxy. For numbers that must be complete, like orders and revenue, the better answer is to send those events from the customer's server, where they're facts the business records anyway, and keep the browser for behavior.
Check your understanding · Exercise 8
Which autocapture rules protect user privacy? Select all that apply.
Architecture, data model, and the collector API
Now the pieces fit together. The SDK is a pipeline: a thin public surface on top, a chain of plugins in the middle, and a queue with transports at the bottom. Each box has one job, and the boundaries are where we expect change: plugins change per customer, transports change per browser, storage changes per environment.
Public API runs inside the customer's handlers, and only Transports touches the network. Debug tools are dashed because most users never download them.| Component | Responsibility | Why it's separate |
|---|---|---|
| Snippet and stub | Defines window.analytics, records early calls, injects the SDK | Lives in customers' HTML forever; must stay tiny and frozen |
| Public API | Validates arguments cheaply, stamps messageId and timestamp, pushes to the pending list | The only code that runs inside customers' handlers; must be under 1 ms |
| Config loader | Fetches remote settings: sample rate, enabled plugins, endpoints | Lets us change behavior without customers redeploying |
| Identity and session | Anonymous id, user id, session id, top domain cookie | Storage rules differ per browser and per consent state |
| Plugin pipeline | Consent gate, sampling, context, autocapture, customer plugins | The part customers extend; errors in one plugin stay contained |
| Queue and outbox | Memory buffer, IndexedDB persistence, flush triggers, size caps | The durability logic, tested alone with fake timers |
| Transports | fetch while visible, sendBeacon or keepalive on hide, backoff | Browser-specific; swapped by feature detection |
| Lifecycle watcher | visibilitychange, pagehide, pageshow, online | One place that knows how pages live and die |
| Debug tools (lazy) | Schema validation, console logging, queue inspector panel | Large and only for developers, so never in the default download |
Follow one event end to end. The click handler calls track(); the API stamps it and returns.
In the next idle slot, the pipeline runs: the consent gate checks the state, sampling checks the user, context adds URL, locale, screen size, and library version, and the event lands in the memory buffer. About once a second, the buffer is written to the outbox.
When a trigger fires, the transport sends a batch to the collector, which validates, deduplicates, corrects clock skew, and passes events to the warehouse. On success, the outbox deletes the batch.
POST batches. The proxy is dashed because it is optional, and it must apply the same consent rules as the direct path.messageId values, so even a response lost in transit can't double count.Think first
Some customers install from npm and also have the snippet from a tag manager. What goes wrong, and how does the design handle it?
Two copies of the SDK would each have a queue, each send a page view, and each manage cookies. Events double, and the two copies can fight over the session.
Both builds check for window.analytics.initialized before starting. The second one to load attaches to the first instead of starting its own core, and logs a warning in debug mode. The npm build also exports a function that returns the shared instance, so app code always talks to the one live copy.
Data model: one envelope for every event
Every event, whatever its name, travels in the same envelope. Keeping the envelope fixed is what lets one collector and one warehouse table serve every customer.
type AnalyticsEvent = {
messageId: string; // crypto.randomUUID(), for dedupe
type: "track" | "page" | "identify" | "group";
event?: string; // "Order Completed", for track only
properties: Record<string, unknown>;
traits?: Record<string, unknown>; // identify and group
anonymousId: string;
userId: string | null;
sessionId: number; // session start time in ms
seq: number; // per-session counter; gaps show lost events
timestamp: string; // ISO time of the track() call, device clock
context: {
page: { path: string; referrer: string; title: string };
locale: string; screen: { width: number; height: number };
library: { name: "our-sdk"; version: string };
consent: { analytics: "granted"; marketing: "granted" | "denied" };
};
sampleRate: number; // 0.2 means this event stands for 5
priority?: "key";
};
sentAt for clock skew, dropped for honest gaps). Each event carries everything needed to place it on its own, so batches can arrive in any order.The SDK also keeps a little state of its own between events. The rule for placing it: ids that must survive across subdomains go in a cookie, small values the SDK reads on every event go in localStorage because a synchronous read is fine when it's tiny, and anything that can grow, like pending events, goes in IndexedDB.
| Data | Where it lives | Lifetime |
|---|---|---|
| Anonymous id | Cookie on the top domain, copy in localStorage | Up to 400 days in Chrome; in Safari, deleted after 7 days of use without an interaction on the site |
| User id and traits | localStorage | Until reset() |
| Session id and last event time | localStorage, shared by tabs | 30 minutes of inactivity |
| Consent state | Read from the consent platform each page; our copy in memory | As long as the platform keeps it |
| Pending events | Memory, then IndexedDB outbox | Until acknowledged, capped at 1,000 events |
| Remote config | localStorage plus HTTP cache | 5 minutes, then refreshed |
| Sampling decision | Memory, recomputed from the id | The session |
The collector API
POST /v1/batch HTTP/1.1
Host: collector.example-analytics.com
Content-Type: text/plain;charset=UTF-8
{"writeKey":"wk_live_3f9a","sentAt":"2026-10-21T09:14:03.120Z",
"dropped":0,"batch":[{"messageId":"9b1e...","type":"track",
"event":"Order Completed","timestamp":"2026-10-21T09:14:01.884Z", ...}]}
HTTP/1.1 200 OK
Content-Type: application/json
{"accepted":19,"rejected":[{"messageId":"c41d...","reason":"property total must be a number"}]}
- Auth: the
writeKeyidentifies the customer's project. It's public by design (it's in their HTML), so it can only write, never read. Abuse is handled by rate limits per key and by allowed origins checked against theOriginheader. - Partial success: a batch is accepted even if some events fail validation. The response lists rejected ids and reasons, which the SDK prints in debug mode. The SDK never retries rejected events: they'd fail again.
- Errors:
400for a malformed batch,413for too large,429withRetry-Afterfor rate limits,5xxfor retryable server trouble. These map one to one to the retry rules in the queue chapter. - Limits: 32 KB per event and 500 KB per batch on the server. The client's own caps are lower, so it never hits these in normal use.
Check your understanding · Exercise 9
Why is the writeKey safe to put in public HTML? Select all that apply.
Debugging, testing, and versioning
No user will ever report a missing event, so the SDK must make its own behavior easy to see, easy to test, and safe to change.
A debug mode customers actually use
- Turning it on:
?analytics_debug=1in the URL (saved insessionStoragefor the visit), oranalytics.debug(true)in the console. Nothing about it ships in the core except the check. - What it loads:
sdk-debug.js, with the schema validator, readable console groups per event (name, properties, plugins that changed it, sampling and consent decisions), and the validation result. - The inspector panel: a small panel in the corner, inside a Shadow DOM so the host page's CSS can't break it, listing the queue, the outbox, the last batches with their status codes, and the current ids. The wireframe in What makes this hard shows it.
- Programmatic access:
analytics.debugState()returns the queue length, outbox size, backoff timer, consent state, and sample decision, so customers' own end-to-end tests can assert on it.
Testing the SDK
- Unit tests with fake time and a fake transport: the queue is a pure state machine, so tests drive it with fake timers and assert "20 events cause one batch", "a 503 schedules a retry between 0 and 2 s", "a full queue drops the oldest normal event".
- Browser tests with Playwright in Chromium, Firefox, and WebKit: intercept the collector with
page.route, then check that hiding the page sends a beacon, that consent pending sends nothing, and that a Back navigation from bfcache sends a page view. - Contract tests: the collector's schema for the envelope is shared, and a test sends a real batch to a staging collector on every release.
- Host page tests: run the SDK on a test page that freezes
Array.prototype, defines its ownanalyticsglobal, uses a strict CSP, and loads an old snippet version. These are the pages that break SDKs in the wild. - A size check in CI that fails if the core grows past its gzipped budget.
Versioning and caching the SDK file
Customers never update their snippet, but we must still fix bugs on their sites. So the snippet points at a major version URL like /v1/sdk.js, which always serves the latest compatible release. That file can't be cached for long, or a bad release would stick in browsers for days. The rest of the code lives in files named by content hash, which never change and can be cached for a year.
| File | Example | Cache-Control |
|---|---|---|
| Major version entry | /v1/sdk.js | public, max-age=3600, stale-while-revalidate=86400 |
| Hashed chunks | /v1/core.4f9c2a.js, /v1/autocapture.81bd0e.js | public, max-age=31536000, immutable |
| Remote config | /config/wk_live_3f9a.json | public, max-age=300 |
- Rolling out: a new core goes to 1% of page views first (the entry file picks a chunk by hashing the anonymous id), then 10%, then everyone, while we watch our own error rate and the delivery rate per version.
- Backward compatibility: never remove a method the snippet stubs; old snippets keep working forever. Deprecated options keep working and print a warning in debug mode only.
- Pinning: customers who need Subresource Integrity (an
integrityhash on the script tag) or a strict review process can pin an exact version URL. They trade automatic fixes for control, and the docs should say so plainly.
Check your understanding · Exercise 10
Why is /v1/sdk.js cached for only an hour while hashed chunks are cached for a year? Select all that apply.
Running it in the interview
This question rewards people who see the environment, not the code. Spend your time on the guest rules, the queue, and page death; the API itself takes two minutes.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Scope: embedded or first-party, traffic, how bad a lost event is, regions and consent, browsers | A list of requirements with numbers |
| 5 to 10 | The two rules (do no harm, don't lose key events); the API and the stub snippet | The stub queue sequence |
| 10 to 22 | Architecture, then the queue: batching triggers, outbox, retries, ids, page-hide flush | The architecture boxes and the batch state machine |
| 22 to 32 | Identity and sessions, then consent and sampling | The consent states; the hash-based sample |
| 32 to 40 | Loading and main thread cost, SPA page views, autocapture privacy | The loading timeline |
| 40 to 45 | Testing, versioning, and the interviewer's follow-ups | Nothing new; point back at boxes |
Things worth saying out loud
Draw the architecture early and keep pointing at it. When you talk about the queue, point at the outbox; when the interviewer asks about privacy, point at the consent gate in the pipeline. It shows that every answer has a home in the design.
- "
track()only captures and returns; everything else happens in idle time, because it runs inside the customer's click handler and counts toward their INP." - "Delivery is at least once with a client-generated event id, so the collector can dedupe retries."
- "I flush on
visibilitychangeto hidden withsendBeacon, keep under the 64 KB budget, and never add anunloadlistener because it hurts the back/forward cache." - "Sampling is per user with a hash, never per event, or funnels break."
- "While consent is pending, nothing is stored and nothing is sent."
Follow-up questions to expect
Think first
"The customer fires Order Completed and immediately redirects to a payment provider. Events go missing. Why, and what do you do?"
The redirect starts unloading the page while the event sits in the memory buffer waiting for a batch trigger. A normal fetch started now may be cancelled.
Key events skip batching and go out at once with sendBeacon (or fetch with keepalive), which the browser completes even after navigation. We also give track() an optional callback, called once the beacon is queued, for customers who want to wait before redirecting. The best fix for revenue events is sending them from the server as well.
Think first
"You shipped a release that throws on some old Android browser. How do you limit the damage?"
Staged rollout means it reached 1% of page views first. Every entry point is wrapped in try/catch, so the host page keeps working; the SDK reports its own errors to its own endpoint, where an error-rate alarm per version fires.
Rollback is a change to the short-cached entry file pointing back at the previous hashed chunk, which reaches most browsers within about an hour. A remote config kill switch can also turn off a single plugin within minutes.
Think first
"How would this change for a React Native or mobile web view SDK?"
The core (pipeline, queue, envelope, ids) is plain TypeScript and carries over. The platform adapters change: storage becomes AsyncStorage or SQLite, page hide becomes the app moving to the background, and there's no cookie or ITP problem but there are app store privacy labels and OS tracking prompts.
This is a good reason to keep platform code behind small interfaces from day one: storage, transport, and lifecycle.
Check your understanding · Exercise 11
An interviewer asks for the single biggest risk in your design. Which answers show senior judgment? Select all that apply.
Summary
- The SDK is a guest on pages it doesn't own: keep it tiny, async, and impossible to crash the host.
track()only captures and returns. - A stub queue snippet lets pages call the API before the SDK loads; it records call times and replays in order.
- Batch by count, size, and time; persist to an IndexedDB outbox in idle time; retry with jittered backoff; dedupe with client-made event ids.
- Flush on
visibilitychangeto hidden withsendBeaconunder the shared 64 KB budget, never withunload, and handle bfcache restores. - Keep the anonymous id in a top-domain cookie, know Safari's 7-day cap on script-written cookies and storage, stitch identities on
identify(), andreset()on logout. - Sample per user with a deterministic hash, and carry the rate on each event so the server can scale counts.
- Consent first: while pending, store nothing and send nothing. Respect GPC, avoid personal data by default, and be honest about first-party proxies.
- Ship a short-cached entry file and long-cached hashed chunks, roll out in stages, and give customers a debug mode, because no user will report a missing event.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Navigator.sendBeacon() How beacons work, their limits, and why to send on visibilitychange.
- MDN: visibilitychange event The last reliable lifecycle event on mobile.
- Chrome for Developers: Page Lifecycle API Hidden, frozen, and discarded states, and which events fire when.
- web.dev: Back/forward cache Why unload listeners hurt the Back button and how to handle pageshow.
- web.dev: Optimize long tasks Yielding to the main thread so third-party work doesn't hurt INP.
- MDN: Prioritized Task Scheduling API scheduler.postTask priorities and browser support.
- MDN: Navigation API Watching same-document navigations without patching history.
- WebKit: Full third-party cookie blocking and more Safari's 7-day cap on script-writable storage.
- WebKit: Tracking Prevention in WebKit The current ITP rules: script-written cookies and storage, link decoration, and CNAME and IP cloaking.
- Chrome for Developers: Deprecating the unload event The rollout that stopped unload handlers from firing in Chrome.
- W3C: Beacon sendBeacon's processing model, no-cors mode for safelisted types, and the shared keepalive quota.
- MDN: Window.fetchLater() Deferred requests the browser sends when the page goes away.
- Segment: Analytics.js quickstart A real stub-queue snippet, with its guard against being pasted twice.
- Google tag: Install gtag.js The dataLayer array and the gtag() function that pushes onto it.
- Global Privacy Control The navigator.globalPrivacyControl signal and the Sec-GPC header.
- Google: Consent mode overview Consent categories and default and update states for Google tags.
Concepts behind it
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.