The question is older than most of the tools people suggest for it. A feature request titled “Page Rule statistics” was opened on the Cloudflare Community on 23 May 2017, raised again by its author in 2018 and in 2019, and closed on 6 June 2025 without ever being implemented. In between, someone running short links on printed material asked whether there is a way to know how many hits a redirect rule got in a given timeframe, and received no substantive answer at all before the thread auto closed.
The advice that does exist is to put a page in front of the redirect so that JavaScript has somewhere to run. That is not a workaround. That is switching the redirect off.
Here is why nothing you already have can see the click, and what actually counts it.
Why every analytics tool you own is blind to this
Not a gap in any particular product. A structural fact about what a redirect is.
Every privacy friendly analytics tool in common use — Cloudflare Web Analytics, Plausible, Umami, Counterscale — works the same way: a small script on the page reports that the page was viewed. Cloudflare’s own installation instruction is the clearest statement of the problem:
Add the JS snippet to any of your website’s HTML pages before the ending body tag.
A 301 response has no HTML page. It has no body tag. There is nowhere to put the beacon and nothing to run it, so the visitor is counted at the destination, if the destination is yours, and not at all if it is not.
A Cloudflare employee said as much in 2023, answering someone who wanted Web Analytics on a domain that only redirects: Web Analytics captures the metric from the client side, and when the source returns a redirect to a third party rather than a page, that cannot be tracked.
Plausible has the same hole, reported in April 2022, answered with a roadmap promise, and still open.
The phase order, which is the real explanation
Cloudflare publishes the order in which its request phases run, and reading it answers the question completely.
Single Redirects run in http_request_dynamic_redirect, the first application layer phase.
Bulk Redirects run in http_request_redirect, fifteenth. Snippets run in
http_request_snippets, nineteenth. And the rules documentation is explicit about what
happens on a match:
For terminating actions (Block, Redirect, or one of the challenge actions), rule evaluation will stop and the action will be executed immediately.
So a redirect that matches produces the response there and then. Everything later in the pipeline never runs, which includes the one place you could have written a log line. Even paying for Snippets does not help: they sit four phases after Bulk Redirects and never see a request that already left.
This is the whole reason the problem has no configuration answer. The thing that counts the click has to be the thing that issues the redirect.
What the built in answer costs
Cloudflare does have a product that hands you raw request logs, and its availability table is short:
| Plan | Logpush |
|---|---|
| Free | No |
| Pro | No |
| Business | No |
| Enterprise | Yes |
That is the honest reason this article exists. On any plan below Enterprise, the log you want is something you have to write.
Not KV, and this is worth being specific about
The suggestion you will find in older threads is to write each click into Workers KV, one key per click or one counter key, and read it back with a list. Both shapes are wrong, for different documented reasons.
The free plan allows 1,000 writes per day. A redirect that gets any real traffic exhausts that before lunch, and then silently stops recording.
More fundamentally, KV limits you to one write per second to the same key, on every plan. A single counter key is exactly the access pattern KV is built to refuse. Paying more does not change that number.
The thing that does work: a Worker and Analytics Engine
Workers Analytics Engine is built for exactly this shape of data, and it is on the free plan:
| Free | |
|---|---|
| Data points written | 100,000 per day |
| Read queries | 10,000 per day |
| Retention | three months |
Compared with KV’s thousand writes, that is a hundred times the room, and the write path is designed for one event per request rather than a mutable counter.
Bind a dataset:
# wrangler.toml
[[analytics_engine_datasets]]
binding = "CLICKS"
dataset = "redirect_clicks"
Then let the Worker issue the redirect and record it on the way past:
export default {
fetch(request, env) {
const url = new URL(request.url);
const target = "https://example.com/offer";
env.CLICKS.writeDataPoint({
indexes: [url.hostname],
blobs: [
url.pathname,
url.searchParams.get("gclid") ?? "",
request.headers.get("referer") ?? "",
request.cf?.country ?? "",
],
doubles: [1],
});
return Response.redirect(target, 301);
},
};
Four details in those twenty lines matter.
Do not await the write. Cloudflare’s guidance is explicit that writeDataPoint is non
blocking and returns immediately, with the runtime persisting the data in the background.
Awaiting it would add latency to a hop whose entire job is to be fast.
The index is the sampling key, and the limits are one index per call, at most 96 bytes. Put something low cardinality there — the hostname, the campaign — not the full URL. Blobs are where the high cardinality detail goes: up to twenty of them, 16 KB in total, with 250 data points per invocation.
Read the click ID here, at the only hop that is guaranteed to see it. This is the same parameter that a misconfigured Bulk Redirect quietly deletes; if your own Worker is the redirect, you decide what survives instead of choosing between two halves of a query string.
Response.redirect(target, 301) is a real 301. No interstitial, no meta refresh, no
JavaScript. Search engines see a permanent redirect, and the visitor sees one hop.
Reading it back
Analytics Engine has a SQL API. One curl, one token with Account Analytics Read:
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/analytics_engine/sql" \
--header "Authorization: Bearer $CF_API_TOKEN" \
--data "SELECT blob1 AS path,
SUM(_sample_interval) AS clicks
FROM redirect_clicks
WHERE timestamp >= NOW() - INTERVAL '7' DAY
GROUP BY path
ORDER BY clicks DESC"
SUM(_sample_interval) rather than COUNT() is not a stylistic choice. At volume the data is
downsampled, and each stored row then stands for several real ones:
_sample_intervalindicates what the sample rate is for this row (that is, how many rows of the original data are represented by this row)
Count the rows and you will under report, by a factor that changes with traffic. Sum the interval and the number is right at any volume.
What this gives you, and what it does not
It gives you a count of requests, with whatever dimensions you chose to write, on a hop that happens before the browser goes anywhere. Nothing to consent to, nothing for a blocker to block, no cookie, because the click is a request to your own hostname rather than a script loaded from someone else’s.
It is not attribution. Every request is counted, including bots and preview fetchers, and nothing here ties a click to a conversion that happens on a page you do not own. That is a harder problem and a different article.
And it changes what your redirect is. A Redirect Rule is configuration: no deploy, no code, no one to maintain it. A Worker is software, with a rollback and a person who has to understand it later. For one link that trade is usually worth it. For the full list of ways to redirect and what each one costs, the comparison is worth reading before you move a working rule into code.
Where this stops being twenty lines
One redirect, one Worker, one dataset: an afternoon, and the numbers are yours.
The shape changes when the redirects are a portfolio. Every link that needs counting needs the Worker in front of it, every domain needs the route, and the dataset needs someone to query it on a schedule rather than when somebody wonders. At that point you are maintaining a small analytics product as a side effect of wanting to know how many people clicked. That is the job 301.st does by default. For a single link, the Worker above is the whole answer, and it is free.