This blog's homepage shows a post count. Looked like a small feature. Turned out to need three implementations in a week to get right.
Version one: fetch the list, count it
Fetching the whole article list was just the fastest way to ship a count: reuse the existing /articles endpoint, take list.length. Reading one number meant pulling full Markdown content, tags, and metadata for 86 articles, every load. A dedicated endpoint was already filed as a backlog item at the time, a known follow-up, not a mistake found later.
Version two: a dedicated endpoint, still slow and still costly
GET /articles/count: a plain SELECT VALUE COUNT(1) against Cosmos DB. One query, no article bodies.
Cheaper per call, but every call still went straight to the backend, an Azure Container App that scales to zero. The counter renders on the homepage, the single most-hit page on the site, so this was also the single most reliable way to keep waking a cold container. Beyond the added latency, every wake-up scales the container to a replica it wouldn't otherwise need, which is a direct cost, not just a slower page. I realized this later when I saw that the counter took around 30s to count from 0 to the actual count.
Version three: remove it from the request path
- A Cosmos DB Change Feed trigger on the Articles container fires on any write.
- An Azure Function (the one already running comment moderation, reused rather than duplicated) recomputes the count and
POSTs it to/internal/article-counton the existing api-proxy Cloudflare Worker, authenticated with a shared secret header (X-Article-Count-Sync-Key). - That Worker writes the value into
article-count, a key in the same KV namespace it already uses for other durable fallbacks. - The public
/articles/countendpoint on that Worker only reads that key. No origin call, ever.
Stronger than the existing caching elsewhere on the site, which races a fresh fetch against a fallback and only serves the fallback if the real one is slow, still cold-starting on the very first hit to a route since nothing's cached yet. A pure KV read has nothing to race.
Two choices here, both about not building more than needed: the trigger lives in the existing Function app (same managed identity, hosting, monitoring already in place) with its own lease container (ArticlesLeases), not a second app; and the Function reaches Cloudflare through an internal Worker endpoint with a shared secret, not Cloudflare's own API with a token, the same shape as an existing cache-invalidation call. A KV write from outside Cloudflare is "ask a Worker to do something Cloudflare-native," not "call an external API for a result."
What actually went wrong
Deployed cleanly. Did nothing for over an hour.
A no-op write to an article, to fire the trigger, followed by a direct KV check: key didn't exist.
First check: was the shared Function app even healthy? A broken lease container can take a whole host down with it. Two real test comments through the existing moderation pipeline processed normally in ~20 seconds. Host was fine. One of them also surfaced an unrelated bug: scored 0 with "could not parse the model's response," on an otherwise fine comment. The model had appended one stray extra } after a well-formed JSON response; the parser rejected the whole thing instead of recovering the object that was actually there. Fix: extract the first balanced {...}, discard trailing noise, instead of requiring the entire response to be exactly one JSON value.
Application Insights showed the trigger firing on every write, ruling out Cosmos, the lease container, and RBAC. The trace was specific: Article count sync call returned Unauthorized. Reaching the Worker, getting rejected.
Cause: the same secret exists under three names -- a GitHub Actions secret, a Function app setting, a Cloudflare Worker secret. The Worker side had been set under the GitHub secret's name instead of the name its own code reads (ARTICLE_COUNT_SYNC_SECRET). The Worker never saw a secret at all, so every request failed closed with 401 regardless of what the Function sent.
Fixed under the right name, the next write updated KV within seconds. Last surprise: the homepage still showed 0, in one browser only. Cache, not code.