For engineers
How a visitor is counted — and when we forget.
This page is the implementation, not a white paper. The homepage stays quiet on purpose. If you need to defend the design to counsel or to yourself, start here.
What the browser sends
cf.js is a first-party request to /api/collect via navigator.sendBeacon POST (JSON body, not a query string). GET is only a fallback. The script allowlists utm_source, utm_medium, utm_campaign, utm_content, utm_term and drops every other query — tokens and emails never leave the browser. SPA navigations hook pushState / replaceState / popstate. Campaign tags stick to the first pageview in the tab; inner routes do not inherit them. Referrer is origin + path, no query. The origin of the request is whatever served the file — so a path proxy or c. subdomain never talks to our hostname. Setup is on the site, under “Serve from your domain”.
sid— the site’s public idh— hostnamep— pathname plus allowlisted UTMs on the first hit onlyr— referrer origin + path, query strippede— optional event name fromcfa('signup')
No cookies written. No localStorage. No canvas, audio, or font probe. No second heartbeat. That is why we will not show time-on-page: without a close beacon it would be a guess, and we do not guess.
The same-day visitor
Unique visitors need some memory. A cookie is memory the browser keeps. We use memory the server forgets at midnight.
On ingest we read the request IP from x-vercel-forwarded-for (Vercel’s client IP; we ignore spoofable x-real-ip) and the user-agent. We compute:
pepper = COOKIEFREE_HASH_PEPPER // env, never in the database
salt = 32 random bytes for this UTC day
stored in visitor_salts, deleted when the day rolls
HMAC-SHA256(pepper, 0x01 || salt || 0x00 || siteId || 0x00 || ip || 0x00 || ua)
→ first 16 bytes, hex
IP is an ingest input. It is not written to pageviews.
UA is parsed into browser/OS/device, then discarded as a raw string.
Yesterday’s salt is gone, so yesterday’s hash cannot be remade.Plausible hashes with a daily salt they delete. We do that, and add a pepper that never sits next to the analytics rows. Concatenating the UTC date into SHA-256 (what we shipped first) is deterministic: anyone with IP logs and the formula could remake Monday’s digest. A deleted salt plus a pepper they do not have cannot.
When the UTC day changes, the same person on the same IP and browser produces a different digest. Monday cannot be joined to Tuesday. A 30-day “visitors” number is a sum of daily uniques, not a de-duplicated population.
Two people behind one NAT on the same browser string collapse to one visitor for that day. One person on phone and laptop is two. That is the trade for not leaving an identifier on the device.
This is still personal data
Hashing an IP is processing of personal data. We say that in the product, not only in a policy. We do not store the raw IP. We do not keep a mapping table. We do not try to reverse the digest. Counsel can still call the brief processing legitimate interest or a similar basis — that decision is yours. We will not dress the hash up as “anonymous”.
What else the server adds
- User-agent is parsed into browser / OS / device buckets (Chrome, iOS, Mobile…) and then discarded as a raw string.
- Obvious bots (and a list of preview/uptime agents) return 204 and write nothing.
- 60 beacons per hashed IP per minute, counted in Postgres so every function shares the quota. One IP flood: 204, no row. A busy site is not dropped. Raw IP is not stored.
- Referrer is cleaned to a source: Google, X, Hacker News, or the hostname. Same-site referrers become Direct.
- Country, region, and city are copied from the edge headers (
x-vercel-ip-country,x-vercel-ip-country-region,x-vercel-ip-city, with Cloudflare fallbacks). Region is stored asUS-CAso California is not Canada. We do not store the IP, latitude, longitude, or postal code, and we do not run a geo-IP database. Empty if the edge did not send them. - UTM source, medium, campaign, content, and term are copied off the path.
Visits and duration
A visit is the same daily hash with no gap over 30 minutes. Bounce is a visit with one pageview. Visit duration is the last pageview minus the first in that visit. One-page visits are 0 seconds and they stay in the average — we do not pretend we watched the tab. There is no heartbeat, no engagement event, and nothing extra in cf.js. Time-on-page for a single URL would need that heartbeat; we do not ship it.
What we refuse
Cross-day identity. Session replay. Heatmaps. Scroll depth that needs a listener. Fingerprints. A stored UUID. Cross-site graphs. Selling or enriching rows. Anything that would make the script grow a second kilobyte for a metric we do not believe.
Events
cfa('signup') is the same beacon with e=signup. Events do not count toward the paid pageview band. Goals in the dashboard are named aliases of those event strings, or of a path.
Where it lives
Beacons hit Frankfurt. Pageviews sit in EU Postgres. See the Europe page for what that does and does not cover (OAuth and public fonts are not your pageview log).
Questions engineers actually ask
- How do you count a unique visitor without cookies?
- HMAC-SHA256 of site, IP and user-agent, keyed with a server pepper and a random salt that exists only for that UTC day. We store 16 bytes. The IP is discarded. Tomorrow the salt is deleted, so the same person is a new digest.
- Why not just SHA-256(site + IP + UA + date)?
- That is deterministic. Anyone with edge IP logs and the formula can remake Monday’s hash. A deleted random salt plus a pepper that is not in the database cannot. HMAC is the keyed primitive; concatenation is not. This is the visitor-side moat — recipe on /methodology.
- How is this different from Plausible?
- Same job: daily salt, then forget. We add a pepper (COOKIEFREE_HASH_PEPPER) that never sits next to the pageview rows. They publish the AGPL code; we publish the recipe on /methodology. We still cannot join Monday to Tuesday.
- How is this different from Simple Analytics?
- They refuse any visitor identifier. Their “visitors” are unique pageviews (landings). We accept a same-day hash so bounce (30-minute visit, one page) and entry pages mean a person-for-a-day. If “never touch an IP, even in memory” is the bar, they are stricter.
- How is bounce counted?
- A visit is the same daily hash with no gap over 30 minutes. Midnight still resets the hash. Bounce is a visit with one pageview. Views/visit uses visits, not unique visitors.
- How is visit duration counted?
- Last pageview minus first pageview in that 30-minute visit. A one-page visit is 0 seconds, and those zeros are included in the average — same as Plausible. There is no heartbeat and no extra beacon. That is not time-on-page.
- Can you reverse the hash or follow someone across days?
- Not from what we keep. Yesterday’s salt is gone. The pepper is not in the analytics database. A 30-day visitors number is a sum of daily uniques, not a de-duplicated population. This is still processing of personal data — we do not call it anonymous.
- How does the beacon leave the browser?
- POST JSON via navigator.sendBeacon. The payload is not in the URL, so CDN access logs do not see ?email= or ?token=. The shipped script does not GET. The collect URL still accepts a stripped GET if something else calls it.
- Which query parameters do you keep?
- utm_source, utm_medium, utm_campaign, utm_content, utm_term. The script allowlists them. The server allowlists them again. Everything else is dropped before it leaves the tab.
- Do you store the full referrer?
- No. Origin + path, no query. Source labels (Google, Hacker News, Direct) come from the hostname. Tokens in a referrer query never land on the row.
- Does it count client-side / SPA navigations?
- Yes. pushState, replaceState, popstate. The same path is not counted twice. Campaign tags stick to the first pageview in the tab — inner routes do not inherit them. After the landing, the referrer we send is the previous path on your host, so those hits are Direct, not another HN session.
- How small is the file with all of that?
- Under 1 KB gzipped, measured from /cf.js on this build. HMAC is server-side. POST, the allowlist, SPA hooks, and a prerender skip are in the script. Auto click-listeners are not.
- Do prefetch and bots inflate the count?
- Prerender/prefetch is dropped in the script (document.prerendering) and again if Sec-Purpose says prefetch. Library and crawler user-agents are dropped. Hostname must belong to the site, except on the public demo. Trailing slashes are folded so /pricing and /pricing/ are one path.
- Do you store city-level location?
- The edge already classified the request. We copy country, ISO 3166-2 region (as US-CA so California is not Canada), and a city name. We do not store IP, latitude, longitude, or postal code, and we do not run MaxMind or any other geo-IP database. If the host sent nothing, the fields are empty.
- What stops someone flooding /api/collect?
- Public ingest is not unforgeable. 60 beacons per hashed IP per minute — one flood, not a busy site. The counter lives in Postgres, so every Frankfurt isolate shares it. Over that IP quota we return 204 and write nothing. A popular site is not dropped. Raw IP is not stored on that row.
Shorter version for everyone else: what the script sends. Legal-plain: privacy.