cf.completefrontend
My progress
SYSTEM DESIGN · #64

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 Hovered is noise. A lost Order Completed breaks 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

Targets for the SDK. The size and main thread numbers matter more than delivery speed.
RequirementTargetWhy this number
Loader sizeUnder 1 KB gzipped, inlineIt sits in every customer's HTML and blocks nothing
Full SDK sizeUnder 25 KB gzippedClose to a medium image; about 100 ms of parse and compile on a mid-range phone
Main thread work per track() callUnder 1 msA page may call it 20 times during a click handler; we must not add to INP
No long tasksNo task of ours over 50 ms50 ms is the long task line; long tasks delay input
Delivery rateAbove 99% for normal events, above 99.9% for key eventsLost events are silent; no one notices until a report is wrong
Latency to dashboardSeconds to a minute is fineAnalytics is not real time; this lets us batch
NetworkOne request per 10 to 30 eventsEach 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-consent problem 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.

  1. 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.
  2. 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.
  3. 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 Completed before a redirect, happen exactly when the page is going away.
  4. 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.
  5. 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.

Wireframe of a customer's product page with the SDK on it. In the head, an inline snippet under 1 KB. Below it, the customer's product page with a Buy now button whose click calls track. At the bottom, the consent banner from the consent platform with Accept all and Reject. On the right, the SDK inspector shown only in debug mode, listing queue 7, outbox 0, last batch 200, consent pending, sample in at 0.2, and the anonymous id.shop.com/product/42head: inline snippet, under 1 KBCustomer's product pageBuy nowclick calls track()Consent banner (CMP)Accept all / RejectSDK inspectorqueue: 7outbox: 0last batch: 200consent: pendingsample: in, 0.2anon: 4f2c-9adebug mode only
The SDK owns almost nothing on screen. The snippet sits in the head, the page and the banner belong to the customer, and the inspector appears only when a developer turns on debug mode. The click on 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?

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

The verbs most analytics SDKs share. reset() on logout and use(plugin) round out the surface.
MethodWhat it recordsExample
track(name, props)Something the user didtrack("Plan Upgraded", { plan: "pro" })
page(name?, props?)A page or screen view, with URL and referrer filled inpage("Pricing")
identify(userId, traits)Who the user is, after login or signupidentify("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?

Load the SDK synchronously in the head

A plain script tag without async or defer, so it's ready before any other code runs.

Pros

  • Dead simple: the global exists before the customer's code runs.

Cons

  • Blocks HTML parsing until our file downloads and runs: 100 to 500 ms on a slow phone, straight onto their LCP.
  • If our CDN is slow or down, their page hangs. A third party should never be a single point of failure for someone else's site.

Ask customers to wait for a ready callback

Expose onAnalyticsReady(fn) or a promise; customers wrap every call.

Pros

  • No hidden queue; behavior is explicit.

Cons

  • Customers forget, and calls made early are lost or throw.
  • Clutters every call site in their code.

Stub queue snippet

A tiny inline script creates window.analytics as an array with the same method names. Each stub method pushes [method, time, ...args] onto the array.

It then adds the real SDK with async. When the SDK runs, it replays the array in order and replaces the stubs with real methods.

Pros

  • Customer code can call the API at any time, in any order, and nothing is lost.
  • The real file loads in the background and never blocks parsing.

Cons

  • The snippet is code in thousands of customers' HTML that we can't update, so it must be tiny and stable.
  • Methods that return values (like user().id) can't work on the stub; they need a callback form.

npm package only

Customers import { createAnalytics } and bundle it.

Pros

  • Types, tree shaking, no global.

Cons

  • Many customers (marketing sites, CMS pages, tag managers) have no build step. The SDK ships late inside their main bundle.

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.

js
(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");
Our snippet: under 500 bytes minified and about 350 gzipped. It's plain ES5 so it runs in any browser, and it records the time of each call so queued events keep their real timestamps.

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.

ts
// 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
Draining the stub queue. The global is swapped only after the array is empty, so no call is lost and none jumps ahead of an earlier one.
Sequence diagram of the stub queue. The page code calls page, which the stub array stores. The stub injects an async script tag from the CDN. The page code calls track Hero Clicked, also stored with its time. Two seconds later sdk.js arrives and the SDK core starts. The core drains the stub array, replaying both calls in order with their original times, and only then replaces the global. After that, the page's track calls go straight to the real methods.Page codeStub arrayCDNSDK corepage()add async scripttrack(): Herosdk.js, 2 s laterreplay 2 callsdirect calls now
Nothing the page calls is lost or delayed by our download, and nothing throws. The core empties the array before it swaps in the real methods, so nothing is lost or reordered, and each replayed event keeps the time it was really called.

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.

ts
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;
}
Plugins run in stage order: 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.

ts
// 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
Types catch most mistakes at build time for npm users. Snippet users get the same check from the runtime validator in debug mode.

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.

The split. Most users only ever download the snippet and the core.
PieceLoaded whenBudget (gzipped)
Inline snippet (stubs + loader)With the HTMLUnder 1 KB
Core: queue, transport, identity, session, consent gate, page contextAlways, async12 to 18 KB
Autocapture (clicks, forms)Only if enabled in the remote configAbout 5 KB
Debug tools and schema validatorOnly in debug mode20 to 30 KB, never seen by real users
Destination plugins (send to other tools)Only those the customer turned onVaries

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?

All of it inside track()

Validate, enrich, serialize, persist, and maybe send, all before returning.

Pros

  • Simple; the event is safe the moment track() returns.

Cons

  • Adds directly to the customer's interaction time. Synchronous localStorage writes can take several ms on slow devices.

Capture now, process in idle time

track() only copies the arguments, stamps the time and event id, and pushes onto an in-memory array: well under 1 ms.

A scheduled task later enriches, serializes, persists, and sends in small chunks, using scheduler.postTask with background priority, requestIdleCallback, or setTimeout as fallbacks.

Pros

  • The click handler returns fast; our work fills gaps the browser isn't using.

Cons

  • An event sits only in memory for a moment, so a crash right then loses it. The page hide flush in a later chapter covers the normal case.

A Web Worker

Post each event to a worker that queues and sends.

Pros

  • Batching and serialization leave the main thread entirely.

Cons

  • Starting a worker costs a file download and memory on every page view, more than the work it saves.
  • postMessage still copies the event on the main thread, and navigator.sendBeacon doesn't exist in workers.

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.

ts
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);
}
Feature detect the scheduler, and stop every slice after about 8 ms so we never come close to a 50 ms long task. 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.
Timeline of the first 3 seconds of a page view, in five lanes. HTML parse runs from 0 to about 0.6 s. The hero image downloads from 0.2 to 1.4 s, which is the LCP. The SDK file downloads at low priority from 0.3 to 1.3 s, then runs a short init. After that, idle work runs in slices of about 8 ms spread out to 3 s. A user click at about 2 s calls track, which takes under 1 ms.HTMLparseHero imagehero: LCP 1.4 sSDK filelow priorityinitIdle workUser clickclicktrack(): under 1 ms0 s1 s2 s3 s
The SDK never sits in front of parsing or the hero image, and its work after load comes in short slices with gaps between them. The click lane is the one that matters for INP: 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 on crypto.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?

localStorage

Read and write a key like ajs_anon_id.

Pros

  • Simple, not sent with every request, 5 MB of room.

Cons

  • Scoped to one origin: www.shop.com and checkout.shop.com get different ids, so a funnel across them splits one person in two.

First-party cookie set by our script

document.cookie on the top domain, like Domain=shop.com, so every subdomain sees it.

Pros

  • Works across subdomains. Readable on the server too, so server-side events can use the same id.

Cons

  • Sent with every request to that domain, so keep it tiny.
  • Safari's ITP deletes cookies set from script, like the rest of a site's script-writable storage, after 7 days of Safari use without the user interacting with the site. It caps them at 24 hours when the visitor arrives through a link with query parameters from a site ITP classifies as a tracker.

First-party cookie set by the customer's server

The customer's own server, or a proxy on their domain, sets an HttpOnly cookie in a response.

Pros

  • Not capped by the script-cookie rule, so it can live for months (Chrome caps every cookie at 400 days).

Cons

  • Needs work on the customer's backend.
  • Since Safari 16.4, if that server is a CNAME to a vendor, or its IP address differs from the site's in the first half (the first 16 bits for IPv4), Safari caps the cookie at 7 days too.

No stored id (cookieless)

The server derives a daily id from a hash of IP, user agent, site, and a salt that rotates every day, as Plausible does.

Pros

  • Nothing stored on the device, which simplifies consent in many cases.

Cons

  • No returning-visitor data beyond one day; no cross-day funnels or retention.

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.

ts
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
}
Probing for the widest domain that accepts a cookie. Browsers reject public suffixes such as co.uk, so the first success is the registrable domain.

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().

Identity stitching. On Monday a laptop browses anonymously as a1 and sends anonymous events to the warehouse. Later the laptop logs in and identify sends an event with both a1 and u_812. A phone with anonymous id p7 logs in too and sends p7 with u_812. The warehouse now maps a1 and p7 to user u_812. A note says reset on logout creates a new anonymous id.Laptop, Mondayanon a1Laptop loginidentify(u_812)Phone loginanon p7 + u_812Warehousea1, p7 = u_812anonymous eventsboth idsphone joins u_812reset() on logout:new anon id
The SDK doesn't merge anything itself. It only makes sure one event carries both the anonymous id and the user id; the warehouse joins the history. 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

A batch goes out when any trigger fires first. Customers can tune count and time; the size cap is fixed by the transport.
TriggerOur valueReason
Count20 eventsBig enough to cut requests by about 20 times, small enough that one failure doesn't hold back much
Size32 KB of JSONLeaves room under the 64 KB budget that page-hide requests share (next chapter)
Time5 s after the first event waitingBounds delay on quiet pages that never reach 20 events
Page hiddenAlwaysThe last chance we may get
Key eventRight away, with whatever is already waitingOrder 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?

Memory only

A plain array in the SDK.

Pros

  • Fastest; zero storage work.

Cons

  • Lost on reload, crash, or a tab the browser discards. Offline events vanish.

localStorage

Serialize the queue to one key after each change.

Pros

  • Works everywhere, and can be read synchronously at startup.

Cons

  • Every write blocks the main thread, and rewriting a 200 KB queue on each event is slow.
  • About 5 MB per origin, shared with the customer's own data. Filling it can break their app.

IndexedDB outbox

One record per event in an object store, written in batches from a background task.

Pros

  • Asynchronous, so it doesn't block. Much larger quota. Records can be deleted one by one after a batch succeeds.

Cons

  • More code. Asynchronous writes may not finish if the page is killed mid-write, so the page-hide path must send rather than save.

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, and 5xx. Wait with exponential backoff and full jitter: a random wait between 0 and min(60 s, 1 s x 2 to the power of attempt). Honor a Retry-After header 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) or 413 (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 messageId from crypto.randomUUID() when track() 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 minus sentAt and shifts every event's timestamp by it.
State machine for events in the queue. Buffered in memory goes to Persisted in the IndexedDB outbox during idle time. A flush trigger moves it to Sending with fetch. A 2xx response moves it to Acked. A 5xx or offline error moves it to Backoff, a jittered wait, and a timer or the online event moves it back to Sending. A 400 moves it to Dropped, which is logged. When the page is hidden, Buffered goes to Beaconed, handed to sendBeacon with no reply.BufferedmemoryPersistedIDB outboxSendingfetch, batchAckedBeaconedno replyBackoffjittered waitDroppedloggedidleflush2xx5xx or offlinetimer, online400page hiddendelete from the outboxonly after a 2xxa resent beacon is fine:the collector dedupes ids
Events leave the outbox only from Acked. Beaconed events stay marked in the outbox in case that write never lands; if they are sent again, the collector drops them by 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.

Network
Queue lab at t=0 s. Memory buffer: 0 events, 0 KB, oldest waiting 0 s. No batch in flight. Outbox after hide: 0. Delivered: 0. Sent by beacon: 0. Network online. Tab visible.Memory bufferemptycount: 0 / 20size: 0 / 32 KBoldest: 0 / 5 sNo batch in flight0 requests so farDelivered 0, beacon 0outbox after hide: 0
Add events. A batch goes out at 20 events, 32 KB, or 5 s after the oldest one waits.

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?

Normal fetch

The same request the queue uses all the time.

Pros

  • Full control and a readable response.

Cons

  • The browser may cancel it when the page is unloaded. Events die with the tab.

navigator.sendBeacon(url, data)

Hands a small POST to the browser, which sends it even after the page is gone. Returns true if the browser accepted it.

Pros

  • Built for exactly this; supported in every current browser.

Cons

  • No response, so no retry based on the result.
  • Only POST, no custom headers. A string body is sent as text/plain, a CORS-safelisted type, so there is no preflight. Other types need a preflight, and Chrome refuses a Blob whose type isn't safelisted.
  • Shares one budget of 64 KB (64 KiB) of in-flight data per page with keepalive fetch requests; over it, the call returns false.

fetch(url, { keepalive: true })

A normal fetch that the browser lets outlive the page.

Pros

  • Custom headers and methods; a response if the page is still alive.

Cons

  • Same 64 KB shared budget. Headers like Authorization add a CORS preflight, which is one more round trip that might not finish.

fetchLater()

A newer Chromium API: you register a request, and the browser sends it when the page is gone or after a delay.

Pros

  • Removes the guesswork of which event to listen to, and has its own quota, separate from the beacon budget.

Cons

  • Chromium only (since Chrome 135); not in Safari or Firefox, so it must be feature detected and treated as an extra.
  • Its quota is still 64 KB at a time per destination origin.

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.

ts
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);
The page-hide flush. A string body is sent as 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.
Sequence diagram of delivery when the page is hidden. The user switches apps. The browser fires visibilitychange with hidden to the SDK. The SDK calls sendBeacon with 58 KB, which returns true. The browser posts the batch to the collector after the page is frozen, and the collector answers 204 with no one listening. A second beacon returns false because it would pass the 64 KB budget, so those events stay in the outbox, and on the next visit the SDK sends them with fetch.UserSDKBrowserCollectorswitches appvisibility: hiddensendBeacon: truePOST after freeze204, no listenernext beacon: falsenext visit: fetch
The browser, not our code, finishes the request after the page is gone. When 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.

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?

Ask customers to call page() in their router

Document a hook for React Router, Next.js, Vue Router.

Pros

  • Exact: the app knows when a view really changed.

Cons

  • Many customers never do it, or do it twice.

Patch the History API

Wrap history.pushState and replaceState, and listen to popstate for Back and Forward.

Pros

  • Works in every browser and every router that uses the History API.

Cons

  • Monkey patching a global another script may also patch; we must always call the original and never break the chain.

The Navigation API

Listen to navigation.addEventListener("currententrychange", ...), which fires after any same-document navigation commits.

Pros

  • One clean event for links, history calls, and Back or Forward; no patching.

Cons

  • Newly available everywhere: Chromium has had it since Chrome 102, while Safari 26.2 (December 2025) and Firefox 147 (January 2026) only just shipped it, so older versions still need the fallback.

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.

ts
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);
}
Route watching with the Navigation API and a History fallback. The patch calls the original first, so the app's router never sees a difference.
Sequence diagram of an automatic page view in a single page app. The user clicks the Pricing link. The router calls pushState, or the Navigation API fires, and the SDK watcher hears it. The router renders Pricing for the user. On the next frame the watcher reads the new title. The watcher sends page for /pricing to the queue. Later a hash-only change reaches the watcher and is skipped.UserRouterSDK watcherQueueclicks PricingpushState or navrenders Pricingnext frame: titlepage(/pricing)hash only: skipped
Waiting one frame lets the router set 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, or select values.
  • 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 /account or /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.

The SDK's architecture. Inside the customer's page, page code calls the public API, which stamps and pushes events. The API hands them, in idle time, to the plugin pipeline for consent and sampling, which also reads ids and the session from the identity store. The pipeline puts events in the outbox, held in memory and IndexedDB. Transports read the outbox and send batches to the collector with fetch or sendBeacon. The lifecycle watcher tells the transports when the page is hidden. Remote config from the CDN feeds the public API. Debug tools are a lazy, optional part.Customer pagePage codetrack(), page()Public APIstamp, pushPlugin pipelineconsent, sampleIdentityids, sessionOutboxmemory + IDBTransportsfetch, sendBeaconDebug toolsLifecycle watcherRemote configCDN, 5 minCollectorPOST /v1/batchbatchhidden
Only Public API runs inside the customer's handlers, and only Transports touches the network. Debug tools are dashed because most users never download them.
Components and why each boundary sits where it does.
ComponentResponsibilityWhy it's separate
Snippet and stubDefines window.analytics, records early calls, injects the SDKLives in customers' HTML forever; must stay tiny and frozen
Public APIValidates arguments cheaply, stamps messageId and timestamp, pushes to the pending listThe only code that runs inside customers' handlers; must be under 1 ms
Config loaderFetches remote settings: sample rate, enabled plugins, endpointsLets us change behavior without customers redeploying
Identity and sessionAnonymous id, user id, session id, top domain cookieStorage rules differ per browser and per consent state
Plugin pipelineConsent gate, sampling, context, autocapture, customer pluginsThe part customers extend; errors in one plugin stay contained
Queue and outboxMemory buffer, IndexedDB persistence, flush triggers, size capsThe durability logic, tested alone with fake timers
Transportsfetch while visible, sendBeacon or keepalive on hide, backoffBrowser-specific; swapped by feature detection
Lifecycle watchervisibilitychange, pagehide, pageshow, onlineOne place that knows how pages live and die
Debug tools (lazy)Schema validation, console logging, queue inspector panelLarge 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.

Data flow end to end. The CDN serves sdk.js and remote config to the browser SDK. The SDK sends batches of events, optionally through a first-party proxy on the customer's domain at shop.com/ingest, which forwards them to the collector. The collector validates and dedupes, then streams events to the warehouse, ordered by event time. Dashboards query the warehouse for funnels and retention. The collector replies to the SDK with 200 and any rejected ids.CDNsdk.js, configBrowser SDKqueue, outboxProxyshop.com/ingestCollectorvalidate, dedupeDashboardsfunnels, retentionWarehouseby event timecodebatchesforwardstream200, rejected ids
Only two things cross the network from the browser: a code download from the CDN and POST batches. The proxy is dashed because it is optional, and it must apply the same consent rules as the direct path.
Sequence diagram of a track call with a failure. The click handler calls track Plan Upgraded, and the SDK returns in 0.2 ms. In idle time the SDK saves 20 events to the IndexedDB outbox. The SDK posts the batch to the collector, which answers 503. After a backoff of 1.4 s the SDK retries, and the collector answers 200 with 20 accepted. The SDK deletes the 20 events from the outbox.Click handlerSDKOutbox (IDB)Collectortrack() callreturns, 0.2 msidle: save 20POST /v1/batch503retry after 1.4 s200: accepted 20delete 20 events
The user's click finishes long before any network work starts. The 503 costs nothing visible: the events wait in the outbox, and the retry carries the same 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?

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.

ts
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";
};
The event envelope. Note what's absent: no IP field and no raw user agent string; the server reads both from the request and decides what to keep.
The event envelope as a data model sketch. A batch holds writeKey, sentAt, and dropped, and up to 20 events. Each event has four groups of fields: Who, with anonymousId, userId, and sessionId; What, with type, event, and properties; When, with timestamp and seq; and Context, with page, locale, and consent. Every event also has a messageId for dedupe and a sampleRate for scaling.Batch: writeKey, sentAt, droppedEvent (up to 20 per batch)WhoanonymousId, userId, sessionIdWhattype, event, propertiesWhentimestamp, seqContextpage, locale, consentplus messageId (dedupe) and sampleRate (scaling)
The batch carries what is true for the whole request (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.

Where each piece of client state lives. Nothing is written to any storage while consent is pending.
DataWhere it livesLifetime
Anonymous idCookie on the top domain, copy in localStorageUp to 400 days in Chrome; in Safari, deleted after 7 days of use without an interaction on the site
User id and traitslocalStorageUntil reset()
Session id and last event timelocalStorage, shared by tabs30 minutes of inactivity
Consent stateRead from the consent platform each page; our copy in memoryAs long as the platform keeps it
Pending eventsMemory, then IndexedDB outboxUntil acknowledged, capped at 1,000 events
Remote configlocalStorage plus HTTP cache5 minutes, then refreshed
Sampling decisionMemory, recomputed from the idThe session

The collector API

http
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"}]}
One endpoint for all event types. The body is JSON sent as text/plain so the same request works from fetch and sendBeacon without a preflight.
  • Auth: the writeKey identifies 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 the Origin header.
  • 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: 400 for a malformed batch, 413 for too large, 429 with Retry-After for rate limits, 5xx for 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=1 in the URL (saved in sessionStorage for the visit), or analytics.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 own analytics global, 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.

A short-lived entry point with long-lived chunks. A fix reaches a browser within about an hour, plus one page view: after the hour, stale-while-revalidate runs the cached copy once while it fetches the new one. Repeat visitors download almost nothing.
FileExampleCache-Control
Major version entry/v1/sdk.jspublic, max-age=3600, stale-while-revalidate=86400
Hashed chunks/v1/core.4f9c2a.js, /v1/autocapture.81bd0e.jspublic, max-age=31536000, immutable
Remote config/config/wk_live_3f9a.jsonpublic, 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 integrity hash 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.

A 45-minute plan. If the interviewer pulls toward one area, follow them and skip the later rows.
MinutesWhat to doWhat to draw
0 to 5Scope: embedded or first-party, traffic, how bad a lost event is, regions and consent, browsersA list of requirements with numbers
5 to 10The two rules (do no harm, don't lose key events); the API and the stub snippetThe stub queue sequence
10 to 22Architecture, then the queue: batching triggers, outbox, retries, ids, page-hide flushThe architecture boxes and the batch state machine
22 to 32Identity and sessions, then consent and samplingThe consent states; the hash-based sample
32 to 40Loading and main thread cost, SPA page views, autocapture privacyThe loading timeline
40 to 45Testing, versioning, and the interviewer's follow-upsNothing 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 visibilitychange to hidden with sendBeacon, keep under the 64 KB budget, and never add an unload listener 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?"

Think first

"You shipped a release that throws on some old Android browser. How do you limit the damage?"

Think first

"How would this change for a React Native or mobile web view SDK?"

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 visibilitychange to hidden with sendBeacon under the shared 64 KB budget, never with unload, 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(), and reset() 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next