My site is static, on Cloudflare. I want to know how many people read which page and how many scanned the QR code on the flyer, without a cookie banner and without sending my visitors to someone else’s server.
Cloudflare already ships a free answer to the first half of that, and for many sites it is enough. This guide starts there, shows where it stops counting, and then builds the alternative: a cookieless counter, a Worker on a path of your own site that counts page views, short link clicks and QR scans into a D1 database in your own account. Every Cloudflare limit below was checked against Cloudflare’s documentation on 11 October 2026.
What Cloudflare gives you for free
Web Analytics is free, and Cloudflare describes it as “privacy-first analytics” that “does not collect or use your visitors’ personal data”. On a proxied hostname you turn it on in the dashboard and Cloudflare injects its script into your HTML for you. Its FAQ sets the terms: data for the previous six months, and “We retain unsampled beacon data for the past 7 days, after this point data is aggregated down to around 10%.”
If your pages load it, you can live with a script from another host, and you only care about pages, stop here. It costs nothing and you configure nothing.
Where Web Analytics stops counting
A strict Content Security Policy refuses it. The script lives at
static.cloudflareinsights.com/beacon.min.js. Automatic setup sends its reports to your own
domain’s /cdn-cgi/rum, so connect-src 'self' is fine, but the script itself is a third
party host, and the FAQ asks you to add it to script-src. This blog is the measured case. Its
policy is script-src 'self' 'unsafe-inline'. On 11 October 2026 a page of 301.sh carried the
injected tag, with an integrity attribute and the site’s token, and Chrome refused it:
blockedURI: https://static.cloudflareinsights.com/beacon.min.js
violatedDirective: script-src-elem
disposition: enforce
The resource entry has a transfer size of 0 and the beacon object never appears. Cloudflare injects the tag without looking at the policy, so the dashboard stays empty and nothing tells you why except a line in the browser console.
Blocker lists refuse it by name. Cloudflare’s FAQ has a section headed “The analytics
beacon is blocked by ad-blockers” and answers it with “Cloudflare is aware”. The
EasyPrivacy list, checked on
11 October 2026, blocks the host, ||cloudflareinsights.com^$third-party, and also carries
generic path rules that match any site: /beacon.min.js, /cdn-cgi/rum? and /cdn-cgi/rum|.
Reporting to your own domain does not help when the path is the same on every Cloudflare site.
It counts pages, not links. A QR code on a flyer, a link in a newsletter or in a bio leads to a page only if you make it one. The click itself, and which code was scanned, is not a page view.
The idea: a collector on a path of your own site
A Worker can sit on a route that covers one path of your hostname, say
example.com/assets-k3/*. Everything outside that path goes to your origin as before, and so
does anything under it that the Worker does not handle, because the Worker passes it on with
fetch(request). The page sends one small request to that path on load. To the browser, the
CSP and the blocker lists it is a same origin POST to a path that looks like any other.
This works on a static site too. Cloudflare’s Pages documentation describes deploying a Worker “on the same custom domain as your Pages project”, and the TDS guide on this blog relies on the same arrangement. One order matters, from Pages’ known issues: “It is currently not possible to add a custom domain with … a Worker already routed on that domain.” Attach the Pages custom domain first, then add the route.
The smallest version is one table, one Worker and one line in the page:
CREATE TABLE views (day TEXT, page TEXT, n INTEGER, PRIMARY KEY (day, page));
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === '/assets-k3/v') {
const day = new Date().toISOString().slice(0, 10);
const body = (await request.text()).slice(0, 200);
const page = body.startsWith('/') ? body : 'unknown';
await env.DB.prepare(
'INSERT INTO views (day, page, n) VALUES (?, ?, 1) ' +
'ON CONFLICT (day, page) DO UPDATE SET n = n + 1'
).bind(day, page).run();
return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
}
return fetch(request);
},
};
<script>navigator.sendBeacon('/assets-k3/v', location.pathname)</script>
The route is example.com/assets-k3/* with a D1 binding named DB. The page sends its own
path in the body. The Referer of the beacon would carry it too, but only while the site’s
Referrer-Policy allows it: no-referrer drops the header and origin cuts it to the host,
and every view would land on /. A body that is not a path is counted as unknown. When you
want sources, send document.referrer alongside it. The slice only caps what reaches D1:
request.text() has already read the whole body by then, so a counter facing the open
internet reads the stream itself and cancels it past a limit, as clx does at 2 KB.
Pick the path the way you would name a folder of images. A name like stats, track or
beacon is what the generic rules above are written for.
Counting people without a cookie
A view counter is a sum. A visitor counter needs to know whether it has seen someone before,
and the usual way to know is a cookie. The way without one is a hash of things the request
already carries, salted with a random value that lives for one day:
sha256(day salt | site | IP | user agent). Store the hash for the open day, count the
distinct hashes, and when the day closes keep the count and delete both the hashes and the
salt. The salt has to come from crypto.getRandomValues and never reach a log. After that nothing in the database can
be matched to a person, including by you.
What this counts is distinct combinations of address and browser per day, which is close to people but not the same. A phone that leaves the home network for mobile data counts twice; two colleagues behind one office address with the same browser count once. Over a week the number is the sum of daily uniques, so one person reading on Monday and on Thursday counts twice. The report should say so in its label.
Short links and QR codes on the same Worker
A short link is a redirect, and a redirect runs no JavaScript, so no page script will ever see the click. Counting a click on a redirect explains why only the thing that answers the request can count it. If a Worker already counts views into D1, it can answer the links too and write the clicks into the same tables.
The links want a hostname of their own, such as k7q.example.com, with no origin behind it.
Workers Custom Domains
fit: “Cloudflare will create DNS records and issue necessary certificates on your behalf.” The
documentation only names an existing CNAME as a blocker. Measured on a live account on
11 October 2026, any existing record stops it: the API answers 409 with code 100117,
“already has externally managed DNS records”, so a record someone made by hand is never taken
over.
Three details make the counts trustworthy:
- answer with
302andcache-control: no-store, so every click reaches the Worker instead of a cached redirect in the browser; - put a marker in the address printed in the QR code,
k7q.example.com/menu?q, record it as the sourceqrand drop it from the redirect; - count a
GET, redirect aHEADwithout counting it, because link checkers and preview fetchers send those.
Rules by country and device fit in the same lookup. request.cf.country gives the country,
the user agent gives mobile or desktop, the first rule that matches wins and the link’s main
target catches the rest.
One more thing the small answers buy. Since June 2025 Cloudflare has
reported
that Russian ISPs let visitors load “only the first 16 KB of any web asset” from sites behind
it. A counter like this sends an inline script under 600 bytes and gets an empty 204; a link
answers with headers only, under 1 KB with Cloudflare’s own. Both fit far below the cut. This
was measured from outside Russia, by size, not from inside it. It does not rescue a page that is
itself larger than the cut: a page that never loads sends no view to count. A short link fares
better, because its redirect arrives whole and the visitor goes on to wherever the target is
hosted.
What it costs on the Free plan
Workers Free gives an account 100,000 requests a day and 10 ms of CPU per request. D1 on Free gives 5 million rows read and 100,000 rows written a day, 500 MB per database and 5 GB per account. The quotas reset at 00:00 UTC and are shared by every Worker in the account.
Writes run out first. Each counted event touches at least one row, more once you keep visitor hashes and per hour detail, and D1 counts every index entry as a row written. clx, the counter described below, budgets nine writes for the worst event and puts the guaranteed ceiling at about 11,000 counted events a day on Free, with a typical site several times that.
When a quota runs out the failures differ. Over the request limit Cloudflare
returns Error 1027 or passes the
request to the origin, depending on how the route is set to fail. Either way nothing is
counted, and the short links stop answering, because their host has no origin to fall back to.
Your pages are not on the route and keep loading. Over the write limit D1 “will return
errors”, so counting stops. Whether links keep redirecting depends on the code: the sample
above awaits its write and would fail with it. clx writes a click with
ctx.waitUntil() after the redirect is sent and drops a failed write, so its links keep
working. Workers Paid lifts both for
a minimum of $5 a month per
account, with 10 million requests and 50 million D1 writes included.
Where it breaks
- It is not fraud resistant. The path is in the page source, and anyone can
POSTto it with anyRefererand user agent. Checks against theOriginand the host keep out browser noise, not a determined script. - The route must be free. If another Worker already holds a route on that pattern, Cloudflare refuses a second one. Pick another path rather than overwrite it.
- Your inline script needs your CSP’s permission. With
script-src 'self'and no'unsafe-inline', serve the same code as a file from the Worker’s path,<script defer src="/assets-k3/a.js">, which'self'allows. The beacon itself falls underconnect-src, so a policy that starts fromdefault-src 'none'needsconnect-src 'self'as well. - Browser Integrity Check is yours. Cloudflare’s zone setting can refuse some scripted clients on the link host before the Worker runs. Browsers pass. Your own monitoring script may not.
- Pages’ own files after the Worker are unchecked here. Whether a Pages project’s
_redirectsand_headersstill apply to requests the Worker passes on was not tested for this guide.
Doing it without writing it
The code above is a working start. Turning it into a counter with sources, countries,
devices, bots, visitor hashes, day rollups, link rules, QR codes and a report is a project of
its own, and that project is clx. It installs a Worker named clx-edge and a D1
database into your Cloudflare account, using a short lived bootstrap token you create, and
keeps only totals on its own side, hourly for two days and daily for 400; the breakdowns stay
in your account. Each site gets its own path and its own generated
snippet, so two sites on clx share nothing in their page source. Links get a host per site,
with a QR code for each link and up to 10 rules. The free plan covers one Cloudflare account,
10 sites and 200 links. The source is open under AGPL-3.0.
Site generators can use it without a browser. The management API connects a customer’s Cloudflare account once, adds each site and returns the snippet to embed at build time; the snippet stays the same across rebuilds. Site Generator, which publishes affiliate sites to Pages, turns it on for a customer who asks its support.
When you need 301.st instead
For one site and a handful of links, the Worker above or clx is the whole answer, and both are free. 301.st is for a different job: many domains with redirects by country and device, traffic split between offers or landing pages, bots filtered before they reach them, and postbacks that tell you which click converted. It runs in your Cloudflare account the same way, and a clx short link can point at a 301.st flow when you need both.