What a beacon actually sends
Follow one page view from the browser to POST /api/collect. Here is the body that leaves the page, the gate it has to pass, and the record that comes back.
"Analytics" usually means a script you can't read sending a request you never see. On this site, one event is small enough to read in full. This note follows a single page view from the moment the browser builds it to the moment the server writes it down.
The Collection Lab shows the browser, the network, and the record side by side. Open it in another tab and follow along. The page view itself is sent by the site-wide tracker, which runs on every page, including the lab.
Before anything leaves: the browser panel
The first panel in the Lab is labeled Browser. It reads navigator, screen, document, and the Performance API: viewport and device pixel ratio, language and time zone, the Network Information API where it exists, navigation timing, Largest Contentful Paint, Cumulative Layout Shift, Interaction to Next Paint, the referrer, campaign parameters, and the names of cookies and localStorage keys.
All of it stays on the device. The panel says so, and the code agrees. A beacon carries a smaller profile, not this list. Reading a value in the page and sending it are two separate decisions.
The gate
A beacon is only sent if two things are true:
- You have turned measurement consent on. The choice is stored in this browser under one localStorage key,
site.consent.v1. Only the valuegrantedcounts. Anything else, or no value at all, counts as off. - Your browser isn't sending Global Privacy Control. If
navigator.globalPrivacyControlis true, the site treats consent as denied, whatever the stored choice says. The server also refuses the post when the request carriesSec-GPC: 1, even if the body claims consent was granted.
Consent is off by default. Google Analytics starts with analytics_storage, ad_storage, ad_user_data, and ad_personalization all set to denied. Accepting turns on analytics storage only. Advertising storage stays off. Turning measurement off sets analytics storage back to denied. That denial is not pushed to the dataLayer.
When consent is off, the Lab still builds the web vital it is about to show you, with consent set to denied. Then it drops it. The Back end panel marks it suppressed and shows "the raw payload that would have been sent". No request is made and the server has nothing to enrich. The site-wide tracker is stricter: with consent off, it doesn't build a page view, a click, or a scroll event at all.
There is one deliberate exception. If you switch consent from on to off, one consent_change is sent so that decision is on the record. Its consent field is denied, and properties.choice is denied. The route stores that single row. The profile on it is the one already in memory when the switch flips, so presentation can still say personalized. The consent field is the part that changed. A first Decline, when consent was never granted, sends nothing. After the withdrawal, nothing else is sent. If the browser is sending Global Privacy Control, the server refuses this post too and stores nothing. The header, the lab switch, and the consent banner are marked so the click tracker skips them. Turning measurement off does not add a click beacon. The consent_change is the withdrawal.
The body
With consent on, here is a page view as it leaves the browser. These are the keys the site-wide tracker writes. The values are one returning desktop visit: morning in Los Angeles, a previous note open, scroll already at 40%, no campaign parameters. Nothing in the object is optional padding.
{
"event_id": "3f6c1a2e-8b14-4c7a-9f02-6d1e0a4b7c93",
"event_name": "page_view",
"occurred_at": "2026-10-05T16:04:11.418Z",
"consent": "granted",
"page": {
"path": "/lab",
"title": "Lab · Black Box Personalization",
"referrer": "/blog/what-the-server-adds"
},
"campaign": {},
"properties": {
"session_id": "7c2a9e14-6b31-4f08-9d2e-1a0b6c4d8e77",
"sequence": 4,
"timestamp": "2026-10-05T16:04:11.418Z",
"path": "/lab",
"referrer_path": "/blog/what-the-server-adds"
},
"client_profile": {
"presentation": "personalized",
"presentation_reason": "rules",
"visitor": "returning",
"visit_count": 3,
"timezone": "America/Los_Angeles",
"local_time": "9:04 AM",
"local_hour": 9,
"time_of_day": "morning",
"device_class": "desktop",
"viewport_width": 1440,
"viewport_height": 900,
"connection": {
"api": "available",
"effective_type": "4g",
"downlink_mbps": 10,
"rtt_ms": 50,
"save_data": false,
"slow": false
},
"referrer_class": "internal",
"referrer_host": "www.blackboxpersonalization.com",
"utm": {},
"pages_viewed": ["/", "/blog", "/blog/what-the-server-adds", "/lab"],
"topics_viewed": ["server", "beacons"],
"scroll_depth": 40,
"click_count": 2,
"last_click": "What the server adds to a beacon",
"rules": [
{ "slot": "hero", "id": "returning-visitor", "name": "Returning visitor" },
{ "slot": "cta", "id": "scroll-depth", "name": "Scroll depth" },
{ "slot": "recommendations", "id": "topic-affinity", "name": "Topic affinity" },
{ "slot": "layout", "id": "standard-weight", "name": "Standard image weight" }
],
"segments": [
{ "id": "new-visitor", "on": false },
{ "id": "returning-visitor", "on": true },
{ "id": "mobile", "on": false },
{ "id": "desktop", "on": true },
{ "id": "engaged-reader", "on": false },
{ "id": "lab-explorer", "on": false },
{ "id": "west-coast", "on": true },
{ "id": "other-region", "on": false },
{ "id": "night-owl", "on": false },
{ "id": "campaign-visitor", "on": false },
{ "id": "signed-in", "on": false }
]
}
}
Read it top to bottom:
event_idis a random UUID made in the browser, so the record can be matched to the request that carried it.event_nameis what happened. The route acceptspage_view,click,scroll_depth,web_vital,form_start,form_submit,consent_change,sign_in,sign_out,outbound_click,location_sample, andprecise_location.occurred_atis the browser's clock, not the server's. Hold onto that. It is the same instant asproperties.timestamp.pageis the path, the document title, and the previous path in this tab. That previous path lives insessionStorageundersite.path.v1. It is notdocument.referrer. The title is cut at 180 characters.campaignis the campaign parameters the tracker reads:utm_source,utm_medium,utm_campaign,utm_term,utm_content, andutm_id. It is an empty object when none of those are on the URL.propertiesalways carries a session id, a sequence number, a timestamp, the path, and the previous path. The session id ends when the tab closes. A scroll event addspercent, one of 25, 50, 75, or 100, and each of those is sent once per path. Extra string properties are cut at 200 characters. The route repeats that cut and keeps at most 20 properties. A web vital is a different body, built by the lab rather than this tracker:metric, a number (value_msfor LCP and INP,valuefor CLS), and arating. It does not carry the session fields.client_profileis the personalization profile this page had at that moment.referrer_classandreferrer_hostcome fromdocument.referrer, which is a separate fact frompage.referrer.rulesis the winning rule for the hero, the call to action, the notes, and the layout.segmentsis every segment flag, on or off.topics_viewedis the topic list from notes this browser has actually opened.
That last field is the one worth reading slowly. A site that personalizes from your reading history and then reports on it is sending that history. Here it is sent in plain JSON, to the site's own origin, and only after you said yes.
The request
The body goes out as a single fetch:
POST /api/collect
content-type: application/json
The site-wide tracker sets keepalive, so the request can finish even if you navigate away. It does not give up on a timer. The lab's own sender, the one that posts web vitals, aborts a request after twelve seconds and caps itself at twelve events in any ten-second window. That cap does not cover scroll events. The route still stores a fast series. Once the same visitor key has 40 stored beacons in 60 seconds, the next row is kept and marked with bot_reason set to rate.
Then look at the Network panel. Once the request finishes, it shows up there as a first-party row, with the same host as the page. Google Analytics rows, from googletagmanager.com and google-analytics.com, are grouped separately and labeled third-party Google Analytics. The Resource Timing list itself is never uploaded.
What comes back
The route doesn't store the body as-is. It wraps it. The response holds the stored record, and the Back end panel shows both blocks side by side when the event is one the lab itself posted: Raw payload sent and Enriched record stored. A page view from the site-wide tracker raises the stored count on that panel. The raw JSON block there is the lab's own event.
The record keeps your body as raw_payload, gives it a record_id, and stamps received_at and stored_in. Then it adds server_enriched_profile. These are the keys, for the same page view, on a request that carried Vercel headers. The address below is a documentation address, not a visitor. On a laptop most of those headers are null, and the reconciliation lines say so instead of inventing a place.
{
"record_id": "a91e4c20-5d77-4b18-8c3f-2e6a0d9b1f44",
"received_at": "2026-10-05T16:04:11.640Z",
"stored_in": "database",
"endpoint": "POST /api/collect",
"raw_payload": {
"event_id": "3f6c1a2e-8b14-4c7a-9f02-6d1e0a4b7c93",
"event_name": "page_view",
"occurred_at": "2026-10-05T16:04:11.418Z",
"consent": "granted",
"page": {
"path": "/lab",
"title": "Lab · Black Box Personalization",
"referrer": "/blog/what-the-server-adds"
},
"campaign": {},
"properties": {
"session_id": "7c2a9e14-6b31-4f08-9d2e-1a0b6c4d8e77",
"sequence": 4,
"timestamp": "2026-10-05T16:04:11.418Z",
"path": "/lab",
"referrer_path": "/blog/what-the-server-adds"
},
"client_profile": {
"presentation": "personalized",
"presentation_reason": "rules",
"visitor": "returning",
"visit_count": 3,
"timezone": "America/Los_Angeles",
"local_time": "9:04 AM",
"local_hour": 9,
"time_of_day": "morning",
"device_class": "desktop",
"viewport_width": 1440,
"viewport_height": 900,
"connection": {
"api": "available",
"effective_type": "4g",
"downlink_mbps": 10,
"rtt_ms": 50,
"save_data": false,
"slow": false
},
"referrer_class": "internal",
"referrer_host": "www.blackboxpersonalization.com",
"utm": {},
"pages_viewed": ["/", "/blog", "/blog/what-the-server-adds", "/lab"],
"topics_viewed": ["server", "beacons"],
"scroll_depth": 40,
"click_count": 2,
"last_click": "What the server adds to a beacon",
"rules": [
{ "slot": "hero", "id": "returning-visitor", "name": "Returning visitor" },
{ "slot": "cta", "id": "scroll-depth", "name": "Scroll depth" },
{ "slot": "recommendations", "id": "topic-affinity", "name": "Topic affinity" },
{ "slot": "layout", "id": "standard-weight", "name": "Standard image weight" }
],
"segments": [
{ "id": "new-visitor", "on": false },
{ "id": "returning-visitor", "on": true },
{ "id": "mobile", "on": false },
{ "id": "desktop", "on": true },
{ "id": "engaged-reader", "on": false },
{ "id": "lab-explorer", "on": false },
{ "id": "west-coast", "on": true },
{ "id": "other-region", "on": false },
{ "id": "night-owl", "on": false },
{ "id": "campaign-visitor", "on": false },
{ "id": "signed-in", "on": false }
]
}
},
"server_enriched_profile": {
"source": "POST /api/collect",
"received_at": "2026-10-05T16:04:11.640Z",
"clock_skew_ms": 222,
"client_reported": {
"presentation": "personalized",
"presentation_reason": "rules",
"visitor": "returning",
"visit_count": 3,
"timezone": "America/Los_Angeles",
"local_time": "9:04 AM",
"local_hour": 9,
"time_of_day": "morning",
"device_class": "desktop",
"viewport_width": 1440,
"viewport_height": 900,
"connection": {
"api": "available",
"effective_type": "4g",
"downlink_mbps": 10,
"rtt_ms": 50,
"save_data": false,
"slow": false
},
"referrer_class": "internal",
"referrer_host": "www.blackboxpersonalization.com",
"utm": {},
"pages_viewed": ["/", "/blog", "/blog/what-the-server-adds", "/lab"],
"topics_viewed": ["server", "beacons"],
"scroll_depth": 40,
"click_count": 2,
"last_click": "What the server adds to a beacon",
"rules": [
{ "slot": "hero", "id": "returning-visitor", "name": "Returning visitor" },
{ "slot": "cta", "id": "scroll-depth", "name": "Scroll depth" },
{ "slot": "recommendations", "id": "topic-affinity", "name": "Topic affinity" },
{ "slot": "layout", "id": "standard-weight", "name": "Standard image weight" }
],
"segments": [
{ "id": "new-visitor", "on": false },
{ "id": "returning-visitor", "on": true },
{ "id": "mobile", "on": false },
{ "id": "desktop", "on": true },
{ "id": "engaged-reader", "on": false },
{ "id": "lab-explorer", "on": false },
{ "id": "west-coast", "on": true },
{ "id": "other-region", "on": false },
{ "id": "night-owl", "on": false },
{ "id": "campaign-visitor", "on": false },
{ "id": "signed-in", "on": false }
]
},
"added_by_server": {
"ip": "203.0.113.24",
"country": "US",
"region": "CA",
"city": "San Francisco",
"latitude": 37.77,
"longitude": -122.42,
"timezone": "America/Los_Angeles",
"edge_region": "sfo1",
"function_region": "iad1",
"database_region": "us-east-1",
"database_label": "us-east-1 · Ashburn",
"parsed_user_agent": {
"raw": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"browser": "Chrome",
"browser_version": "131.0.0.0",
"os": "Mac OS",
"os_version": "10.15.7",
"device_type": null,
"device_vendor": "Apple",
"device_model": "Macintosh",
"engine": "Blink",
"is_bot": false
},
"headers": {
"x-forwarded-for": "203.0.113.24",
"x-real-ip": null,
"cf-connecting-ip": null,
"true-client-ip": null,
"fly-client-ip": null,
"x-vercel-forwarded-for": "203.0.113.24",
"cf-ipcountry": null,
"x-vercel-ip-country": "US",
"x-vercel-ip-country-region": "CA",
"x-vercel-ip-city": "San Francisco",
"x-vercel-ip-latitude": "37.77",
"x-vercel-ip-longitude": "-122.42",
"x-vercel-ip-timezone": "America/Los_Angeles",
"x-vercel-id": "sfo1::iad1::example",
"x-forwarded-proto": "https",
"x-forwarded-host": "www.blackboxpersonalization.com",
"host": "www.blackboxpersonalization.com"
}
},
"timings": {
"request_start": "2026-10-05T16:04:11.418Z",
"server_receipt": "2026-10-05T16:04:11.640Z",
"db_write": "2026-10-05T16:04:11.710Z"
},
"reconciliation": [
"Viewport class (desktop) agrees with the User-Agent reading (desktop, type unset). The page used the viewport.",
"An IP was taken from a forwarding header. This app does not look the address up.",
"A country header was present (US). It was not computed from the raw IP in this app.",
"Vercel edge headers included a city-level point (37.77, -122.42). Latitude and longitude are stored to two decimal places.",
"The client clock says 9:04 AM in America/Los_Angeles (hour 9). The server clock at receipt is 2026-10-05T16:04:11.640Z."
],
"summary": [
"Client profile: returning visitor, morning in America/Los_Angeles, viewport desktop. Presentation is personalized (rules).",
"Server parsed the User-Agent header as Chrome on Mac OS.",
"Edge IP header: 203.0.113.24.",
"Edge country header: US."
]
}
}
bot_reason is not on this record. The route adds it only when the user agent matches a crawler list, looks headless, or trips the rate mark above. The row is still stored.
The panel shows summary as a short list, then reconciliation: lines that explain where the browser's view and the server's view disagree. The usual case is device type. The page decides mobile or desktop from the viewport width, because that's what the layout actually used. The User-Agent parser may say something else, especially for a desktop browser in a narrow window, where device_type is null and the server treats that as desktop. The record keeps both answers and says which one personalization trusted. It doesn't quietly overwrite one with the other.
clock_skew_ms is the server clock minus occurred_at. added_by_server is the part the browser cannot see: the forwarding address, the edge place headers, the edge and function regions, the database region, and the parsed User-Agent. client_reported is the profile the browser sent, copied through so the join stays visible. timings.db_write is filled after the row is inserted. Until then it is null.
The record is written to the database. In production that database is Postgres, and GET /api/collect reports store as database and driver as postgres. A local checkout without a Postgres URL uses SQLite and reports driver as sqlite. The lab reads new rows two ways, from a server-sent event stream and a poll of GET /api/collect every two seconds. The poll keeps running while the stream is open. The stream's in-memory listener only sees rows saved in that same process, so the stream also reads the database every two seconds. A row written on another serverless instance still shows up.
Nothing hidden, on purpose
Put together, one beacon is three things you can check:
- What the browser knows: the Browser panel. It never leaves the device.
- What the browser sends: the raw payload. Only with consent, and only to this origin, plus the one
consent_changethat records turning consent off. - What the server adds: the enriched record. Receipt time, a parsed User-Agent, and whatever headers the edge provided.
Most analytics stacks blur those three into one opaque request. Taking them apart is the whole point of this site. You can't judge what a measurement is worth, or whether it's fair, until you can see which of the three a given field came from.
Open the Collection Lab and turn consent on. A page_view leaves for POST /api/collect, shows up as a first-party row in the network list, and increases the stored count. The raw JSON in the back end panel is the lab's web vital, or, with consent off, that same vital marked suppressed. Scroll past 25% and the site-wide tracker sends a scroll_depth event. Turn consent off after it was on. One consent_change is stored with consent set to denied, and the next scroll is not sent.
More on the server side: What the server adds to a beacon. More on the browser side: What a browser can reveal on page load.
Recommended next
Why these picksReading path transitions…