Tracking6 min read

How tracking works

Every event (a pageview, an outbound click, a track() call) is queued in the browser and POSTed in a batch. What happens to it after that is the part worth understanding, because it shapes what you can and can't do with your data.

Batched POSTs, not a stream

The tracker POSTs to /api/collect (or the equivalent path on a proxied domain). There's no persistent connection and no socket. Events are queued in the page and flushed as one batch inside a shared envelope, so an ordinary pageview costs about three requests rather than one per signal: the pageview itself (which needs the server's reply to decide about session replay), one coalesced batch a second later, and a final flush when the tab is hidden or closed.

  • The queue flushes after a 1 second debounce, or immediately once 10 events are waiting.
  • Each queued entry carries its own path, so a scroll event queued on /a and flushed after a route change to /b is still attributed to /a.
  • sendBeacon is used where possible, so the final flush survives the tab closing mid-request.
  • The body is sent as text/plain, which makes it a CORS simple request and skips the preflight round trip entirely. The server parses it as JSON regardless.
Note

Batching is why a slow network doesn't lose the tail of a visit, and why the tracker is cheap on a busy page. It's also why an event can appear in the dashboard a second or two after it fired, rather than instantly.

Pre-aggregated counters, not raw rows

On the server, that event is turned into a handful of counter increments against a table of daily rollups: one row per (site, day, metric, dimension), each holding a running total. A single pageview might bump pageviews for the day, visitors_by_path for the page it landed on, and visitors_by_country for the visitor's country, each as a +1 against an existing counter rather than a new row.

This is the table every chart on your dashboard reads from. There is no query path that scans individual pageviews to build a chart: the aggregate is already sitting there, computed incrementally as events arrive, so reading it back is a straightforward sum instead of a table scan.

A separate table backs the Events explorer

Custom events, goals, built-in engagement events, and revenue also write one row each to a per-event table: that's what powers the Events list, the visitor inspector's timeline, per-event property breakdowns, and the page sequences on Journeys. Pageviews are recorded there too, without properties (their only prop, the page title, is already recoverable from the path). This table is bounded by your plan's retention window and pruned nightly; the rollup counters are the durable source of truth for every aggregate chart. Only pageviews count toward your plan's quota, whichever table they land in: see what counts toward your quota.

What this means for you

  • Dashboard queries are fast regardless of traffic volume. A busy site's monthly overview is the same shape of query as a quiet one's (summing some counters), because the expensive part (aggregation) already happened when each event arrived, not when you load the page.
  • Dimension cardinality is bounded, on purpose. Metrics with an unbounded number of possible values (page paths, referrer hosts, UTM campaigns, custom event names) keep at most the top 200 distinct values per site per day; the rest fold into a single "Other" bucket. For realistic traffic this cap is invisible. It exists so that one flood of unique URLs or a UTM-spam campaign can't make a single day's storage for one site grow without bound.
  • There's no way to re-run history through a new segment definition. Because the counters are pre-computed at ingest, a breakdown you didn't ask for on day one isn't retroactively derivable from stored rollups the way it would be from raw events. Custom event properties (see Custom events) are the escape hatch: set up the property you want to break down by, and it starts accumulating from that point forward.

Where the numbers come from

Two extra steps happen before an event ever reaches a counter: bot filtering (see Bots and spam) drops anything that isn't a real visitor before it's stored anywhere, and excluded paths drop specific pages you've opted out of tracking. Both checks run before the rollup write, so filtered traffic never inflates a counter you'd have to later subtract from.