The script

What a 1 KB analytics script actually sends.

The client sends five things. The server adds a few coarse fields and then forgets how to recognize you tomorrow.

From the browser

  • Site public ID
  • Hostname
  • Path — query stripped except utm_source / medium / campaign / content / term
  • Referrer — origin and path, no query
  • An optional event name if you call cfa('signup')

That is the whole beacon. No screen size, no language list, no canvas hash, no stored UUID.

On the server

We parse the user-agent into browser, OS, and device, and drop obvious bots. We clean the referrer into a source. The host platform may provide a country, region, and city name — stored without the IP, without coordinates, and without a geo-IP database of our own.

To count one person once per day we HMAC the site, IP and user-agent with a server pepper and a random salt that exists only for that UTC day. Sixteen bytes go on the row. The IP does not. At midnight the salt is deleted — so even we cannot remake yesterday’s digest. That is still processing of personal data. It is not a cookie. The recipe, including why this beats hashing the date, is on the method page.

Where the counts live

Cookie Free Analytics is built in Europe. Beacons hit Frankfurt; pageviews sit in EU Postgres — not a US region with a DPA stapled on. We do not forward beacons to an advertising graph or a warehouse on another continent.

What we refuse

Canvas fingerprinting, font lists, stored identifiers, session replay, scroll maps. Anything that needs a second beacon stays out so the file stays under 1 KB. We also do not invent time-on-page. Without a heartbeat it would be a guess.

How it leaves the browser

POST JSON via sendBeacon. Not a GET with the path in the URL — so access logs never see ?email= or ?token=. SPA navigations hook pushState. Campaign tags stay on the landing page. The HMAC is computed on the server; the recipe is on /methodology.

POST /api/collect
  {"sid":"cf_…","p":"/pricing?utm_source=hn","h":"example.com","r":"https://news.ycombinator.com/"}

Questions

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.