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.

POST to /v

anything else

Visitor opens a page

Page from Pages or your server

Inline script sends a POST
to example.com/assets-k3/v

Worker on the route
example.com/assets-k3/*

D1 in your account

Passed on to the origin

204, no body

A page view counted by a Worker on one path of the site

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.

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.

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 302 and cache-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 source qr and drop it from the redirect;
  • count a GET, redirect a HEAD without 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 POST to it with any Referer and user agent. Checks against the Origin and 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 under connect-src, so a policy that starts from default-src 'none' needs connect-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 _redirects and _headers still 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.