Analytics Events That Answer a Question
A named event with a stable schema answers a question. A firehose of clicks answers none — and carries personal data you did not mean to send.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What should the frontend actually record about what people do, and what makes one event useful and another one noise?
Someone is trying to buy a thing, or finish a form, or find a document. They did not come here to generate telemetry, and they have opinions about being followed while they do it.
Log everything. Attach one delegated listener at the root, send { selector, text, href, url } for every click, and work out the questions later — the data will be there when we need it.
A firehose has no schema. click on button.btn-primary stops meaning anything the moment someone renames a class, and it never meant the same thing in two places to begin with.
- A firehose has no schema.
click on button.btn-primarystops meaning anything the moment someone renames a class, and it never meant the same thing in two places to begin with. - The questions arrive later and the data cannot answer them. "What share of people who started checkout finished it?" needs two named events and something linking them; a table of clicks has neither.
- A click is not an outcome. A click that succeeded and a click that threw look identical, so a broken feature shows up in the data as *engagement* (Frontend Error Tracking).
- Selector-based events break on every refactor. A purely visual redesign silently empties a funnel, and nobody notices until someone asks a question about last quarter.
- The URL you send with every event contains identifiers, search terms, invitation tokens and sometimes an email address, all now sitting in a third-party system (Session Replay and the Privacy It Costs).
- The delegated root listener never fires for interactions where something called
stopPropagation(), so a subset of your interface is simply absent from the data (Event Delegation). - The beacon fires before the consent banner has been answered, which is not a bug in the banner — it is a bug in where the collection call lives.
What is actually happening
In the browser, not in the framework.
- An analytics event is a name, a schema, a time and a subject. All four are a contract: dashboards, funnels, alerts and other teams' queries depend on the name and the property shape exactly as much as a client depends on an API response shape (Backward Compatibility: The Real Rules).
- You design it backwards from the question. "What fraction of people who begin checkout complete it?" fully determines the design: two events,
CheckoutStartedandCheckoutCompleted, sharing a correlation id, plusCheckoutFailedso the denominators reconcile. - A useful event names a domain fact, not an interaction.
CheckoutStartedsurvives a redesign, a framework migration and a change of input device;click_button_3survives none of them. - Delivery is a network problem with an unusual constraint: the page may be going away. Events are batched, sent on a background channel, and flushed on page hide with a mechanism the browser will honour after the document is gone (The Life of a Fetch).
- Because delivery retries and pages get restored, the transport is at-least-once. Every event needs an id so the warehouse can deduplicate rather than double-counting a conversion.
- Consent gates collection, not analysis. The obligation attaches at the moment data leaves the browser. "Send it now and filter later" is not a consent implementation (Real User Monitoring).
- Identity is a spectrum, not a flag. A stable pseudonymous id joined to a behavioural history is personal data. The moment you attach a user id to it, it stops being arguable.
What this makes the browser do
And which of it is avoidable.
- Listeners, property assembly and JSON serialisation on the main thread, in the same task as the interaction the user is waiting on (Interaction Responsiveness).
- A third-party SDK: its bytes, its parse and compile, its own storage, its own cookies and often its own long-lived connection (Third-Party Scripts and the Supply Chain).
- Network sends competing with navigation. A plain
fetchstarted as the page unloads is routinely cancelled; the browser provides mechanisms specifically for this case, and using them is not optional. - A root-level delegated listener that runs for every event in the document, doing work on interactions that will never produce an event.
- Storage for the outbox — queued events that must survive a reload, which means reading and writing on startup (localStorage and sessionStorage).
Design backwards from the question
The discipline is small and almost never practised: before writing an event, write the sentence it will complete. "Of the people who began checkout last week, ___ per cent completed it, and the largest drop was at ___." Once the sentence exists, the events, the properties and the correlation key are all determined, and so is the answer to "do we need this one".
This is also the test that kills most instrumentation requests, which is its main value. An event nobody can attach a question to is an event that will be shipped, downloaded, serialised, sent, stored, and queried by no one — while still carrying whatever it happened to include about the user.
| The question | Events it requires | Properties that make it answerable | What a click firehose gives you instead |
|---|---|---|---|
| What share of started checkouts complete? | CheckoutStarted, CheckoutCompleted, CheckoutFailed | flowId shared across all three; reason on failure | Clicks on a button whose class changed in March |
| Where in the flow do people drop? | CheckoutStepViewed | flowId, step, stepIndex | Page views, if the steps happened to be separate URLs |
| Is the new design better? | The same events, plus variant | variantId, buildId | Two numbers you cannot attribute to either change (Feature Flags in the Client) |
| Did the search actually help? | SearchPerformed, SearchResultOpened | queryLength, resultCount, rank — never the query text | The query string, sent to a third party, containing whatever people type |
| Are uploads failing for anyone? | UploadStarted, UploadFailed | fileCount, sizeBucket, reason — never the filename | A click on "Choose file", which tells you nothing about the outcome (File Upload UX) |
| Which errors do users actually hit? | Error events from the error boundary | errorCode, route, buildId | Nothing — a thrown exception produces no click |
1// The registry IS the contract. Adding a property is fine;2// renaming one is a breaking change for every query downstream.3type Events = {4 CheckoutStarted: { flowId: string; itemCount: number; currency: string }5 CheckoutStepViewed:{ flowId: string; step: 'address' | 'payment' | 'review'; stepIndex: number }6 CheckoutCompleted: { flowId: string; orderId: string; itemCount: number }7 CheckoutFailed: { flowId: string; step: string; reason: 'declined' | 'network' | 'validation' | 'unknown' }8}9 10function track<K extends keyof Events>(name: K, props: Events[K]) {11 queue.push({12 id: crypto.randomUUID(), // at-least-once delivery needs a dedupe key13 name,14 props,15 at: Date.now(),16 ctx: {17 buildId: __BUILD_ID__, // which client produced this18 route: normalisedRoute(), // '/orders/:id', never '/orders/8842?token=…'19 variant: assignedVariants(),20 },21 })22}23 24// Emitted where the domain fact happens — not in a click handler,25// so it fires identically for pointer, keyboard and programmatic paths.26function beginCheckout(cart: Cart) {27 const flowId = crypto.randomUUID()28 track('CheckoutStarted', { flowId, itemCount: cart.items.length, currency: cart.currency })29 return flowId30}Two details do most of the work. flowId is what turns four independent counters into a funnel. Emitting from beginCheckout rather than from a handler is what makes the measurement independent of how the user triggered it (Keyboard Events).
Delivery: a request racing the page going away
Analytics has an unusual transport problem. The most valuable events happen at the end of something — a completion, an abandonment, a navigation away — which is exactly when the document is being torn down and an ordinary fetch is cancelled. The result is a systematic bias: the events you lose are not a random sample.
The platform provides for this, and the shape of a correct implementation is fairly fixed. Batch during the session so interactions are not paying for network calls. Flush when the page becomes hidden, not on an unload event, because a backgrounded mobile page is frequently discarded without one. Use a transport the browser will complete after the document is gone. And give every event an id, because a flush that is retried is a flush that arrives twice.
- 1Domain fact occurs
The code that actually starts a checkout emits the event.
fails by Being wired to a click handler, so keyboard, programmatic and restored-session paths are missing (How an Event Is Dispatched is not intent).
- 2Enrich
Adds event id, timestamp, build id, normalised route, variant assignment.
fails by Adding
location.hrefwholesale, carrying identifiers and query strings off-origin. - 3Consent gate
Decides whether this may leave the browser at all.
fails by Living inside the SDK rather than in front of it, so the SDK has already loaded and phoned home.
- 4Queue
Buffers in memory, with an overflow policy and optional persistence for offline.
fails by Growing without bound in a long-lived tab (Memory Leaks).
- 5Batch send
Posts a group of events on an idle callback or a timer, off the interaction's critical path.
fails by Sending synchronously inside the handler and adding latency to the interaction being measured.
- 6Flush on hide
On
visibilitychangeto hidden, sends the remainder withsendBeaconorkeepalive.fails by Waiting for an unload event that a backgrounded mobile page will never fire.
- 7Ingest and dedupe
Validates against the schema and drops repeats by event id.
fails by Accepting anything, so a renamed property becomes a silently null column.
Every step in this pipeline is a place data is lost, and the losses are correlated with bad experiences — slow networks, crashed tabs, abandoned flows. Read every funnel with that in mind.
Consent, PII, and the URL you did not mean to send
The default event payload of most instrumentation is more revealing than anyone intends, because the fields that are cheapest to include are the ones that carry the most. location.href carries record identifiers, search text, invitation tokens and occasionally an email address in a query parameter. document.title frequently carries a customer name. An innerText capture of the clicked element carries whatever was rendered there.
The fix is not a policy document, it is a function. Normalise the route to its pattern, allow-list the query parameters you have a reason for, bucket anything continuous, and never send free text a user typed. Do it in the enrichment step so it is applied uniformly, rather than at each call site where one person will forget.
- Normalise the route, never send the raw URL.
/orders/:idis the analytical unit;/orders/8842is an identifier. - Allow-list query parameters. Deny by default; a token or a search term will otherwise arrive eventually.
- Bucket continuous values. A file size band answers the question a byte count does, and identifies nobody.
- Never send user-authored text — queries, message bodies, form values, or the rendered text of the element that was clicked.
- Gate before the SDK loads, not inside it. Consent that runs after the script is a preference, not a control.
- Set a referrer policy so third-party requests do not carry your URLs even when your payload does not (Content Security Policy).
- Mask by default in session replay, and treat every unmasked region as a deliberate, reviewed decision (Session Replay and the Privacy It Costs).
track('search', {
url: location.href,
// 'https://app.example.com/orgs/acme-health/search
// ?q=patient%20readmission%20jane%20doe&invite=8f2a…'
title: document.title, // 'Acme Health — Search'
text: el.innerText, // 'Open Jane Doe — 1984-03-02'
userEmail: user.email,
})
// Sent to a vendor, retained under their policy, and now
// subject to an erasure request you cannot service.track('SearchPerformed', {
route: '/orgs/:slug/search', // pattern, not instance
queryLength: q.length, // shape, not content
hasFilters: filters.length > 0,
resultCount: bucket(results.length), // 0 | 1-9 | 10-99 | 100+
})
track('SearchResultOpened', {
route: '/orgs/:slug/search',
rank: index, // answers 'are the top results good?'
resultType: 'patient', // a type, never an identity
})The right-hand version answers the same questions — is search returning results, are the top ranks the ones people open — while carrying nothing that identifies a person or a customer. The minimisation is not a compliance tax added afterwards; it is the same design step that made the events queryable, because a bucketed count aggregates and a raw query string does not (Sensitive Data Classification).
How to build it
Most important first.
- Write the question list first, then the event list, then the code. If nobody can name the question an event answers, it is not an event, it is a log line.
- Name events as domain facts in the past tense, with a stable, versioned property schema:
CheckoutStarted { cartId, itemCount, currency }. Type it at the call site and validate it in CI, so a typo is a build failure rather than a hole in a dashboard. - Emit failures as loudly as successes.
CheckoutFailed { reason, step }is what turns a drop-off number into a diagnosis, and it is the event teams most often forget (Loading, Error, Empty — The States You Did Not Render). - Carry correlation: a flow id that links every event in one attempt, plus the client build id and any variant assignment, so results can be segmented after the fact (Long-Lived Clients and Version Skew).
- Minimise at the source. No personal data in properties. No raw URLs — normalise the path (
/orders/:id), drop the query string, and allow-list the parameters you genuinely need. - Gate collection on consent with a real switch: nothing on the network, and ideally no SDK loaded, before a lawful basis exists. Queue locally or drop, and decide which on purpose.
- Batch, and flush on page hide with
sendBeaconorfetch(..., { keepalive: true }). Give every event a client-generated id so a retried batch deduplicates. - Own the catalogue like an API. Additive changes only; deprecate rather than rename; a shared registry so two teams cannot invent
orderIdandorder_idfor the same thing (Documentation Is Part of the Contract). - Instrument the instrumentation: alert when a named event's volume drops, because a broken funnel is a silent outage that nobody pages for.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Keyboard and assistive-technology users produce a different event shape from pointer users, and a funnel instrumented only against pointer interactions will underrepresent them — then be read as evidence that the flow works for everyone (Keyboard Operability).
- Instrument the domain fact, not the input.
CheckoutStartedis emitted from the code that starts a checkout, wherever it was triggered from; amousedownhandler is a measurement of mice (Pointer Events). - Never attach meaning-carrying analytics only to hover or focus. Hover does not exist for touch or keyboard, and focus events fire for reasons that are not intent.
- The consent banner is a first-class accessibility surface and is very often the worst component on the site: it must be keyboard operable, must not trap focus indefinitely, must offer a reject option reachable in the same number of actions as accept, and must not obscure content it does not block (Focus Management).
- Analytics must never delay the response to an interaction. A synchronous send inside a click handler adds work to the exact task the user is waiting on — queue it and let the batch go later (Yielding and Scheduling).
- The user's consent choice should be confirmable and changeable later, announced when it is recorded, and not re-prompted on every navigation (Live Regions and Announcement).
What can go wrong
- The event fires and nothing consumes it. Half of any mature event catalogue is dead weight that still costs bytes, main-thread time and privacy surface.
- Schema drift. Two teams, two property names for the same value, and every query afterwards has to know about both.
- Events lost on unload, systematically — which does not just reduce the data, it biases it, because the events at the end of a flow are exactly the ones the page is being left after.
- Content blockers and privacy-preserving browsers drop a substantial and non-random share of analytics traffic. Your funnel is a sample of the users who permit measurement, and that population differs from the whole one.
- Consent is implemented in the banner component while the SDK still loads and phones home from the document head.
- An event fired in an effect that runs twice, in development or under a re-mount, so the counts are quietly doubled in one environment and not the other.
- An exposure event fired on render rather than on the element actually becoming visible, inflating the denominator of every experiment that uses it (Feature Flags in the Client).
- Sampling applied inconsistently between two events, so a ratio computed from them is a ratio of two different denominators.
- The page unloading while a batch is in flight — the reason
sendBeaconandkeepaliveexist (Network Failures Only the Client Can See). - Consent being granted after events were queued, so the outbox holds data collected under no lawful basis; it must be discarded or held, never flushed by default.
- Identity resolution: events sent before login and events sent after must be stitched, and the stitching arrives after the earlier events did.
- A retried batch arriving twice, double-counting a conversion unless events carry ids (Five Components, One Request).
- An exposure event racing the flag payload, attributing a user to the default variant (Feature Flags in the Client).
- URLs leak. Identifiers, search terms, invitation tokens and single-use links routinely live in paths and query strings, and an analytics event that carries
location.hrefsends all of it to a third party — as does theRefererheader on the SDK's own requests (CORS). - Everything you send is visible to the user in the network panel: your event taxonomy, your internal names, your experiment keys and your segment labels. Assume the catalogue is public.
- An analytics SDK is a third-party script executing with your page's full authority — your DOM, your cookies, your origin. Its compromise is your compromise (Third-Party Scripts and the Supply Chain).
- Personal data in event properties creates retention, access and erasure obligations inside a system you do not control and probably cannot delete from selectively. The cheapest compliance strategy is not collecting it (Storage Security and Durability).
- Session replay is this problem at maximum intensity: it records the DOM, and the DOM contains everything on screen unless you mask it deliberately (Session Replay and the Privacy It Costs).
- A pseudonymous identifier that is stable across sessions is a tracking identifier, whatever it is called internally. Treat rotating it, and scoping it, as design decisions.
- "We can figure it out later from the raw clicks." Later, the classes have changed, the flow has been redesigned, and the join you need was never recorded.
- "More data is better." More data is more cost, more bias in the parts that were dropped, more privacy surface, and — without a schema — no more answers (The Log Bill and What It Is Buying).
- "Anonymous means not personal." A stable identifier joined to a behavioural trail identifies a person; the absence of a name changes nothing about that.
- "Consent is a banner." Consent is a switch on collection. If the SDK loaded and sent anything before the answer, the banner was decoration.
- "The numbers are the truth." They are a sample: blocked clients missing, end-of-flow events lost, one variant's errors mislabelled as engagement.
- "Analytics is not engineering." It is a public contract, an at-least-once delivery pipeline, a personal-data boundary and a main-thread cost, all in a tag someone pasted into the head.
Measuring it, and what changes in the field
- Volume per event name over time, with an alert on a drop. A funnel that breaks after a refactor produces no error and no page — only a number that quietly goes flat (Release Health).
- Delivery success rate, split by transport: batched during the session versus flushed on page hide. A gap between them is lost end-of-flow events.
- Schema validation failures, both in CI against the event registry and at ingest, so a malformed property is a fixable signal rather than a null column.
- The share of sessions where collection was blocked or declined — the size of the bias, which every conclusion drawn from the data should be read against.
- Funnel conversion segmented by client build and by variant, which is the payoff for having carried a correlation id in the first place (Long-Lived Clients and Version Skew).
- Duplicate rate at ingest, which tells you whether the event id and deduplication are actually working (Retries, and the Duplicate Order).
- On a slow or flaky network, batches fail and retry, so duplicates rise and end-of-session events are lost more often — precisely when the user experience was worst, biasing the data toward good sessions.
- Offline, the outbox has to survive in storage and be replayed later, at which point the event time and the ingest time diverge and only one of them is meaningful (The Offline Mutation Queue).
- In a very long session, a "session" is not a useful unit — someone with a tab open for three days is one session by most definitions and several by any human reading of it.
- On a restore from the back/forward cache, the page comes back alive after its flush already happened, and a naive implementation either double-counts or stops sending entirely (History and Navigation).
- With a content blocker, the SDK may not load at all. The application must not depend on it having loaded, which means every analytics call has to be safe when the queue does not exist.
- Designed events cost real upfront work and can only answer the questions you anticipated. That is the trade, and it is a good one: the firehose alternative costs less upfront and mostly answers nothing.
- A stable event catalogue means you carry deprecated events for as long as anything queries them, exactly like a deprecated API field.
- Minimising data collection loses you the ability to investigate a question you did not anticipate. It also loses you a whole class of obligation, which is usually the better end of the deal.
- Batching improves interaction responsiveness and loses the tail: events queued when the browser is killed are gone.
- Client-side analytics captures what the user actually experienced, including failures the server never saw — and is blockable, biasable and untrusted in a way server-side analytics is not.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALDesigning from the question backwards, treating the event name as a contract, and gating collection rather than analysis are independent of vendor, framework and jurisdiction. Only the specific legal basis and retention rules vary.
- BROWSER-SPECIFICUnload-time delivery is the part that genuinely differs:
sendBeaconandfetchwithkeepaliveare widely available but differ in payload limits and in which page-lifecycle events reliably fire, and mobile browsers are far more aggressive about discarding a backgrounded page without firing anything at all. Flush on visibility change rather than relying on an unload event, and verify per browser. - SPEC-EVOLVINGThird-party storage, referrer defaults and cross-site identifier availability are actively changing across browsers, and privacy-preserving defaults are tightening rather than loosening. Any design that depends on a stable cross-site identifier is building on ground that is currently moving.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Distributed Systems — an analytics pipeline is at-least-once delivery with client-side buffering, so deduplication keys, event-time versus ingest-time, and out-of-order arrival are the same problems here as in any event stream.
- — Software Design — the event catalogue as a published, versioned domain vocabulary owned by more than one team, with the same evolution rules as any other shared contract.