Every analytics product asks you to make a trade. Google Analytics is free but ships your visitors' data to Google and forces a cookie consent banner. Plausible and Fathom respect privacy but cost money and add a script tag. Cloudflare Analytics Engine sits in a different category entirely: it runs inside your Worker, writes data at the edge, and never touches the client.
This site records every page view through it. No cookies, no client-side JavaScript, no consent banner — and the whole thing is about six lines of code. But there's one architectural detail that will trip you up if you don't know it going in, and the docs mention it almost in passing.
The binding
Analytics Engine is a Worker binding, declared in wrangler.toml:
toml[[analytics_engine_datasets]]
binding = "ANALYTICS"
dataset = "ANALYTICS"
That gives you env.ANALYTICS inside the Worker. The dataset is created implicitly the first time you write to it — there's no provisioning step, no dashboard toggle.
Writing a data point
Every HTML request on this site fires a single write:
javascriptenv.ANALYTICS.writeDataPoint({
blobs: [path, request.headers.get("Referer") || "", request.headers.get("User-Agent") || ""],
doubles: [1],
indexes: [path],
});
The schema is unusual at first glance. You don't define columns — you fill three typed arrays:
blobs— strings, up to 20 per data point. Here: the path, the referer, the user agent.doubles— numbers, up to 20. Here: a literal1, so I canSUM()them into a view count later.indexes— a single string used as the sampling key (more on sampling below).
The data model is positional, not named. blob1 is the path, blob2 is the referer, and so on. You have to remember the order yourself — there are no field names stored anywhere. I keep a comment next to the call so future-me doesn't have to reverse-engineer it.
Fire and forget — but never break the page
The write is wrapped in a try/catch that swallows everything:
javascripttry {
env.ANALYTICS.writeDataPoint({ blobs: [...], doubles: [1], indexes: [path] });
} catch(e) {}
writeDataPoint() doesn't return a promise — it's synchronous and non-blocking by design, buffered and flushed by the runtime after the response is sent. But the binding can be undefined in some contexts (local dev without the flag, a misconfigured environment), and a telemetry call should never be the thing that takes down a page. Analytics is the least important byte of the response. If it throws, the visitor should never know.
The part the docs bury: the binding is write-only
Here's the detail that surprised me. From inside a Worker, env.ANALYTICS can only write. There is a writeDataPoint() method and that is the entire API surface. There is no query(), no read(), no way to ask "how many views did this post get?" from within the same Worker that recorded them.
If you try, you get undefined is not a function. The binding is a one-way pipe.
To read your own data, you go through a completely separate channel: the SQL API over HTTPS.
bashcurl "https://api.cloudflare.com/client/v4/accounts/{account_id}/analytics_engine/sql" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-d "SELECT blob1 AS path, SUM(_sample_interval) AS views
FROM ANALYTICS
WHERE timestamp > NOW() - INTERVAL '7' DAY
GROUP BY path
ORDER BY views DESC"
This means reading analytics requires an API token stored as a secret — not a binding. The same Worker that writes the data can't read it back without making an authenticated external HTTP call to Cloudflare's API. For a /status page that wants to show live traffic, that's an extra round trip and a credential to manage, so on this site I show D1-backed view counts there instead and keep Analytics Engine for the dashboard.
Sampling: why you SUM _sample_interval, not COUNT(*)
This is the second thing that bites people. At high volume, Analytics Engine samples — it doesn't store every single data point. To get a true estimate of counts, you don't COUNT(*); you SUM(_sample_interval).
Each stored row carries a _sample_interval column representing how many real events it stands in for. If a row was sampled at 1-in-10, its _sample_interval is 10. Summing those intervals reconstructs the real total; counting rows would undercount by the sampling factor.
The indexes field you set on write is the sampling key — Analytics Engine samples per index value, so high-cardinality indexes (like a unique request ID) defeat the purpose. Using the path as the index keeps sampling coherent per page.
Eventual consistency
Data is not real-time. Writes take up to a few minutes to become queryable, and Cloudflare's docs note the dashboard can lag ~30 minutes behind. For a traffic dashboard this is fine. For anything that needs to read back a value it just wrote — a live counter, a rate limiter — Analytics Engine is the wrong tool. That's what KV and D1 are for. Analytics Engine is for aggregate questions answered after the fact: which posts are popular, where traffic comes from, how it trends.
Why this beats cookie-based analytics
The privacy story isn't a marketing angle — it's a structural consequence of where the code runs:
- No cookies. The visitor is never tagged. There's nothing to consent to, so there's no banner.
- No client JavaScript. Nothing to load, nothing to block, nothing that breaks under a strict Content-Security-Policy. The measurement happens server-side before the response is even sent.
- No third party. Data never leaves Cloudflare's edge. You're not feeding an ad network.
- No PII by default. I store path, referer, and user agent — no IP, no fingerprint, no identifier that follows a person across requests.
The trade-off is real: you lose per-user session tracking, funnels, and anything that requires identifying a visitor across visits. For a personal blog, that's not a loss — it's the entire point. I want to know which posts people read, not who the people are.
When to reach for it
Analytics Engine is the right tool when you want high-volume, write-heavy, aggregate telemetry from inside a Worker and you're willing to query it out-of-band. Page views, API call counts, error-rate sampling, feature-flag hit rates — anything you'll slice and sum later, not read back immediately.
It's the wrong tool when you need to read a value synchronously, when you need exact (non-sampled) counts at low volume, or when you need the data the instant it's written. For those, the write-only binding and the sampling model are exactly the constraints that make it cheap and fast for what it's actually good at.