Counting Page Views Behind a CDN Cache Without Losing Them
Edge caching keeps most page views from reaching your origin, so inline counting dies. Move collection to a beacon and replace the guard routing gave you.
Turning on edge caching for HTML made most page views stop reaching the origin. That was the whole point. It also killed view counting, quietly, because the counter lived on the request path and the request path was now mostly empty. The fix is to move collection to a small POST that the cache never serves. The part nobody warns you about is that the request path was also acting as a validator, and moving off it means rebuilding a guard you did not know you had.
Why inline counting dies behind a cache
Counting views inside the page handler is the obvious place to put it. The request is already there, you know the article, and you avoid a second round trip. It works perfectly until a cache sits in front of you.
Once the edge caches HTML, a cache hit is served without ever touching the origin. Your handler does not run. Nothing increments. The counter now measures cache misses, which is close to the inverse of what you wanted: the more popular a page is, the more reliably it gets served from the edge, and the fewer views it appears to have.
The failure is silent. No error, no alert, no failed request. The numbers keep moving, just slowly and wrongly. If a popular posts widget reads those numbers, it starts ranking by "which pages happened to miss the cache," which correlates with cache eviction and traffic spread rather than with readership.
What the request path was quietly guarding
Here is the part that is easy to miss. The original counter looked like this:
// runs only after routing proved the article exists and is published
if !isBot(r.UserAgent()) {
views.Hit(r.Context(), lang, slug)
}
That placement was doing three jobs, and only one of them was obvious.
The obvious job was counting. The second job was ordering: the counter ran after the handler had already loaded the article and confirmed it was published, so an unknown or unpublished slug could never reach the buffer. Routing was a validator, for free. The third job was rate limiting by physics. A reader can only generate views as fast as they can load pages, and loading a page is expensive enough that nobody thinks of it as an attack surface.
Move collection to a public endpoint and all three change at once. The client now names the slug, so anything can name anything. Nothing has loaded the article, so nothing has proven it exists. And a POST with a short body is cheap enough to send in a loop.
This is the real cost of the migration, and it is not in any of the tutorials. The counter itself is ten lines. The guard you have to rebuild is the rest of the work.
Moving collection to a beacon
The endpoint is deliberately boring. It accepts a POST, records one view, and returns 204 with no body.
func (s *Server) handleHit(w http.ResponseWriter, r *http.Request, lang string) {
w.Header().Set("Cache-Control", "no-store")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBeaconBody))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(string(body))
if !validSlug(slug) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if isBot(r.UserAgent()) {
w.WriteHeader(http.StatusNoContent)
return
}
s.views.Hit(r.Context(), lang, slug)
w.WriteHeader(http.StatusNoContent)
}
Three details matter more than they look.
Cache-Control: no-store is not decoration. If your cache rules are generous, a POST response can end up cached, and then the whole exercise repeats itself one layer down. Most CDNs will not cache a POST by default, but stating it costs nothing and survives a future rule change.
The method must be POST, not GET. A GET beacon is cacheable, prefetchable, and gets fired by link scanners. POST is none of those things.
On the client, sendBeacon is the right call rather than fetch:
if (navigator.sendBeacon) {
navigator.sendBeacon(url, slug);
} else if (window.fetch) {
fetch(url, { method: "POST", body: slug, keepalive: true }).catch(function () {});
}
sendBeacon survives page unload, which fetch without keepalive does not, and it sends a string body as text/plain, which keeps the request in the simple-request category and avoids a preflight. Because the script runs once per page load, you get exactly one beacon per view with no deduplication logic. Back and forward navigation restored from the browser cache does not re-run the script, so history navigation does not inflate the count either.
Replacing the guard you just removed
The tempting fix for arbitrary slugs is to look up the article on every beacon. Resist it. A read per beacon is more expensive than the write you are buffering, and on a metered database it turns a cheap counter into your largest read source.
Three cheap layers replace it, none of which touch the database:
The write is already non-upserting. The counter uses an update, not an upsert, so an unknown slug matches zero documents and changes nothing. No ghost rows appear. This property probably already exists in your code, and it is worth confirming before you build anything else, because it removes the scariest failure mode for free.
The buffer gets a cardinality ceiling. View counts are usually accumulated in memory and flushed periodically. Add a cap on distinct keys per window:
v, loaded := b.counts.LoadOrStore(key, new(int64))
if !loaded && b.keys.Add(1) > maxBufferKeys {
b.counts.Delete(key)
b.keys.Add(-1)
return
}
Existing keys keep accumulating, so real traffic is untouched. Only new keys past the ceiling are dropped. Real cardinality is articles times languages, which for a few dozen articles sits two orders of magnitude below a ten thousand key limit.
The endpoint gets its own rate limiter. Use a separate token bucket from any other limited route. Shared buckets mean abuse of one endpoint locks readers out of another. A reader sends one beacon per article view, so a sustained rate of two per second with a burst of twenty covers several tabs opened at once and still stops a loop.
CORS is not part of that defence
There is a persistent belief that leaving POST out of Access-Control-Allow-Methods keeps other origins from posting to your endpoint. It does not.
A POST with a text/plain body is a simple request. The browser sends it without a preflight. CORS then decides whether the calling page may read the response, and since the beacon ignores the response entirely, the attacker loses nothing. The request already arrived and the view already counted.
So the header does not protect you, and adding POST to it does not help either. Adding it opens preflighted cross-origin calls that were previously blocked, which is strictly more surface for zero benefit, since your own beacon is same-origin and never consults CORS at all.
The correct move is to leave the header alone and write down why, next to it, so the next person does not add POST for the sake of consistency. The defence is the rate limiter and the buffer ceiling. Naming that in a comment is worth more than the header ever was.
The test that guarded the wrong direction
The most useful thing this change surfaced was a test failure. There was a test asserting that loading an article increments its view count, and it broke immediately.
The test was correct when it was written. It is now exactly backwards. Under a CDN, an article handler that increments a counter is the bug, because it means counting has drifted back onto a path that most readers never touch. So the test was not deleted, it was inverted:
// Loading an article must NOT count a view. Collection belongs to the beacon,
// which the cache does not serve. A failure here means inline counting is back.
if got := viewCount(t, srv, st, "hello", "en"); got != 0 {
t.Fatalf("view count = %d on the SSR path", got)
}
Inverting a test rather than deleting it is the whole lesson. A deleted test leaves nothing behind, and six months later someone adds views.Hit back into the page handler because it looks like an oversight. An inverted test states the new contract in the place where the old one used to live, and it fails loudly the moment the old design returns.
Pair it with a test that the beacon script actually renders into the page. The endpoint working and the page calling it are independent failures, and shipping one without the other gives you a working counter that nothing calls.
What to check after you ship
Verify the chain end to end rather than trusting the endpoint alone. Confirm the beacon script appears in the rendered article with the right slug. Confirm the POST returns 204 with a cache status header showing it bypassed the cache. Check origin logs for the POST arriving, since a beacon blocked by a content security policy fails entirely in the browser and leaves no trace on the server. Then wait for one flush interval and confirm the numbers move.
One last thing to write down somewhere visible: the absolute values are now discontinuous. Everything before the change counted cache misses, everything after counts actual views. Relative rankings recover within hours, but a cumulative total that spans the switch is two different measurements added together, and nobody will remember that in three months unless it is in the changelog.
Related posts
Structured Logging That Scales Across Services and Jobs
Free-form logs break once a request crosses services. Here is how JSON logs, a correlation ID, and one shared schema make a distributed request traceable.
Cache Invalidation After Deploy: What to Purge, What Not To
Deploy every day without stale edges or origin overload. Sort assets into immutable hash keys you never purge and mutable HTML you purge by path.
Content-Hash Keys for Media, Immutable and Deduplicated
Store an image under the sha256 of its bytes, not its filename. You get free immutability, automatic dedup, and storage you can move with one env var.
Hot-Swapping Cron Schedules with MongoDB Change Streams
Hardcoded cron means a redeploy every time a schedule changes. Store schedules in the database and have a daemon react to edits with change streams. Here is the design and its failure handling.
Load Balancing a GPU Worker Pool with Redis Keyspace Notifications and Leases
GPUs are expensive and run one heavy job at a time, so round-robin routing stalls behind busy workers. Here is a busy-aware scheduler built from Redis leases and keyspace notifications.
Lease Locks with CAS and Heartbeat for Single-Runner Jobs
A plain lock deadlocks forever when the holder dies. A lease expires. Here is how to build one with compare-and-swap acquisition and a heartbeat, and the failure modes that decide the design.