article
Fine-tuning the new blog, edge cache, purge by tag and real costs
TL;DR Cache rendered pages with the Workers Cache API under a key that includes the build id, and purge by tag, never by URL, from a Storyblok webhook. The zone's Browser Cache TTL can keep stale HTML in browsers for four hours, so the Worker also sends must-revalidate. The cache buys latency and Storyblok headroom, not dollars.
This is part 4, the last part of the series. Part 1 is the overview, part 2 sets up the Workers and part 3 moves the content. This part covers the fine-tuning: the cache, the purge, the costs and what went wrong.
The cache
Without a cache, every page view costs an Astro render and one to three Storyblok API calls. The production Worker stores pages with the Workers Cache API (caches.default).
Cache hit, miss and purge
A GET or HEAD for a page. The Worker runs on every request, hit or miss, before the cache lookup.
The key is the page URL without its query plus ?__build=<build id>. A new deploy has a new id, so it starts with an empty cache.
The stored response goes out with X-Edge-Cache: HIT. No render and no Storyblok call.
Astro renders from Storyblok, the Worker adds Cache-Control and a Cache-Tag, stores the page for 3600 seconds and answers with X-Edge-Cache: MISS.
A Storyblok publish fires a webhook. The Worker asks Cloudflare to purge the page's tags, and the next request in each data center is a MISS again.
Each page carries tags: post-<slug> for an article, series-<slug> for a series, page-<slug> for pages, index, and feed for the RSS and sitemap. The cache does not replicate. Cloudflare's Cache API docs say the contents "do not replicate outside of the originating data center", so each data center pays for its own first render.
Live request to this site's own edge
A cached page from outside, headers filtered:
$ curl -sI https://blog.mikepenz.dev/a/adb-tricks/ | grep -i -E "^(HTTP|cache-control|x-edge-cache|cf-cache-status|age)" HTTP/2 200 cf-cache-status: HIT age: 483 cache-control: public, max-age=0, must-revalidate, s-maxage=3600 x-edge-cache: HIT
Clients never see the cache-tag header. Cloudflare strips it, and the Cache API docs say cache.match() does not return it either.
Purge by tag, not by URL
Cloudflare's how the cache works page warns: "Based on cache API implementation, not all cache purge endpoints function for purging assets stored by the Cache API." The same page says you cannot purge a URL that uses a custom cache key set by a Worker. Entries here live under a key with ?__build= in it, so a purge by URL would miss them. The code only purges by tag.
Purge by tag does work on Cache API entries. Here is the webhook end to end on a settled entry:
19:01:48 HIT age=80 19:01:59 HIT age=91 (pre-check) 19:01:59 webhook 200 {"purged":true,"tags":["index","feed","post-adb-tricks"],"status":200} 19:02:00 GET 200 x-edge-cache=MISS cf-cache-status=null age=null cache-tag=post-adb-tricks
In my runs, a purge evicted within one to nine seconds. One run did not: the webhook arrived in the same second the entry was stored by a MISS, and the page stayed a HIT for over a minute. The cause is unknown, and I did not repeat that case. Cloudflare's purge docs warn that a successful purge request "does not confirm that Cloudflare cached the targeted content or evicted any content", and Storyblok's webhook docs say webhooks don't retry on failure. A purge that fails is never repeated.
That is why the TTL started at 300 seconds and went to 3600 only after the webhook worked end to end. A cache you cannot purge reliably should have a short TTL. In the same-second case, a stale page can last up to an hour.
No webhook secrets, so ?key=
The webhook docs say "Webhook secrets are only available in paid plans". This space has no webhook secret, and without a secret there is nothing shared to check a signature against. So the handler accepts either proof, and either alone is enough:
the
webhook-signatureheader, a hex HMAC-SHA1 of the raw body, ora
?key=query parameter equal toWEBHOOK_SECRET, put into the webhook URL in Storyblok.
The docs do not name the signature algorithm. HMAC-SHA1 in hex is what the handler uses, and a signed test against the live Worker passed. Both proofs are compared in constant time, and an empty or missing secret denies everything. A wrong or missing proof returns 401 and purges nothing.
A key in the URL has costs:
Anyone with access to the Storyblok space can read the webhook URL and so the key. A leaked key can only trigger purges for this site's tags, which means extra Storyblok API load until the cache fills again.
URLs end up in logs. Cloudflare's docs say a fetch invocation log message is
<Method> <URL>, so invocation logs are off inwrangler.jsonc.A webhook has no timestamp, so a captured request can be replayed to trigger purges and Storyblok calls.
The ?key= path works through the real Storyblok webhook: a republish of the license story turned its cached page from HIT to MISS within 15 to 30 seconds. I did not read the webhook delivery log, so that the webhook caused the purge is an inference from the timing.
Set up the webhook yourself
You need three values from the secrets table in part 2: WEBHOOK_SECRET, CLOUDFLARE_ZONE_ID and CLOUDFLARE_PURGE_TOKEN. The production Worker holds all three as secrets.
Step 1 of 5
Generate the key
openssl rand -hex 32Put the output into your secret store as WEBHOOK_SECRET, without echoing it into a shared terminal or a chat.
Create the purge token
In the Cloudflare dashboard: My Profile > API Tokens > Create, permission Zone > Cache Purge > Purge, resources limited to your one zone. Store it as CLOUDFLARE_PURGE_TOKEN, next to the zone id from the zone's Overview page.
Deploy so the Worker gets the secrets
The deploy script from part 2 pipes all three onto the production Worker. Without the purge token, the webhook answers 500, which is what happened to me once.
Add the webhook in Storyblok
Space > Settings > Webhooks. The URL is https://<your-host>/api/webhook?key=<WEBHOOK_SECRET>. Triggers: story published, unpublished, deleted and moved.
Publish something and read the log
The webhook log in Storyblok shows the response. 200 with {"purged":true,...} is success. 500 means the zone id or purge token is missing on the Worker. 502 means Cloudflare rejected the purge. 401 means the key in the URL differs from WEBHOOK_SECRET.
To rotate the key, set the new value, deploy, then edit the webhook URL. Between those two steps the webhook gets 401 and the cache simply keeps its pages until they expire.
The browser cache trap
The first live reading of a cached page had cache-control: public, max-age=14400, s-maxage=300, while the Worker sent max-age=0. The zone setting Browser Cache TTL is 14400 seconds, Cloudflare's default of four hours. Cloudflare's Browser Cache TTL docs say it overrides the header when the origin value is lower. A purge cannot reach a visitor's browser, so repeat visitors could keep stale HTML for four hours.
The Worker now sends must-revalidate too. The capture above shows max-age=0, must-revalidate, with the zone setting still at 14400; I did not pin down why the rewrite stopped. The same docs list a "Respect Existing Headers" option as another way out.
What it costs
Measured from one location that reached one data center (VIE), so cross-data-center misses are not in these numbers:
Hit ratio. 240 of 240 GETs on six URLs were HITs. A poll found exactly one MISS per URL per TTL window, at expiry.
A HIT costs no Storyblok call. Cloudflare analytics showed 0 subrequests in HIT-only minutes. A MISS costs 1 call for a standalone article or the sitemap, 2 for the index or the RSS feed, 3 for a series article or series page.
CPU. About 1.2 to 1.6 ms per HIT, 6 to 21 ms for an uncached render (sampled on HEAD and 404 requests, not a clean GET MISS), and 100 to 262 ms for the first render in a cold isolate.
The Worker still runs on every request. The cache saves the render and the Storyblok calls, not the request.
From Cloudflare's Workers pricing page: the Free plan has 100,000 requests a day and 10 ms of CPU per invocation. The Paid plan costs at least $5 a month with 10 million requests and 30 million CPU ms included, then $0.30 per million requests and $0.02 per million CPU ms. Subrequests are not billed. A simple model (one data center, 300-second TTL, 30 URLs, one Worker request per view, 20 ms per MISS) gives:
Views a month | With cache | Without cache |
|---|---|---|
10,000 to 1 million | $5.00 | $5.00 |
10 million | $5.00 | about $8.40 (CPU overage) |
At 100 million views the requests alone cost about $27 on top of the $5 minimum, with or without the cache. The live TTL of 3600 seconds means fewer misses, and at these volumes the bill would not change from $5. I could not read the Workers plan through the API. I assume Paid because production served requests at 11 to 262 ms of CPU without errors, and the Free plan's 10 ms limit would stop them. On Free, a cold MISS could exceed the limit and fail.
The tighter limit is Storyblok's. Its rate limit page allows 6 requests per second for "Listings with 75 to 100 entries", while cached CDN requests get 1000 per second. The docs do not say whether the 6 per second applies to uncached requests only. The site's lists use per_page=100, the slowest tier, and a MISS can need two lists, so about 3 cold renders per second reach the ceiling. The lists also have no pagination: past 100 articles or 100 series, entries would drop from the index, RSS and sitemap.
Purges have a limit too. Cloudflare's purge docs give Free accounts 5 purge requests per minute, tag purges included, and each webhook is one purge. Publishing 22 stories in a row, as in part 3, would exceed that on a Free zone. I do not know this zone's plan, so it may not apply here.
What went wrong, and the fix
Cache
Degraded renders got cached. A failed series lookup, a feed built from a failed list or a partial index was stored as a normal 200. Fix: any render where a Storyblok call failed is a 503 with
no-store.404, HEAD and POST each cost a Storyblok call. Fix: answer HEAD from the GET copy, cache genuine 404s for 60 seconds, and reject any method other than GET, HEAD or OPTIONS before rendering.
No protection at expiry. There is no lock, so concurrent requests at expiry can each render. The Cache API does not support
stale-while-revalidate, per its docs. This one is still open.workers.devis not a cache no-op. A note in the code said it was. The second GET of an article there was a HIT. The Cache API docs also say it is not available for Workers fronted by Cloudflare Access.
Security
A secrets file got staged. A
git addpicked up.dev.vars.(trailing dot) holding every secret, including the deploy token. It was never committed, and a scan of all git blobs found none of the values. Fix: keep secrets outside the project (here a 1Password Environment), and scan staged files for secret values before every commit.Third-party scripts and no CSP. Production loaded jsDelivr and an analytics script, with no CSP,
nosniffor referrer policy. Fix: bundle the shader library, drop the analytics script, send a CSP withscript-src 'self'andframe-ancestors 'none', plusnosniff, a referrer policy, a permissions policy and COOP. HSTS is stillmax-age=0, a zone setting left as a separate decision.Unsafe HTML. The legacy markdown path passed raw HTML and
javascript:links through. Fix: show raw HTML as escaped text and neutralize unsafe URL schemes, with a test of 20 hostile inputs.Unbounded webhook body. A 20 MB body was accepted and hashed. Fix: read at most 256 KiB and answer 413 above that.
The purge returned 500. The production Worker had no purge token. Fix: set it as a Worker secret, and test the purge after deploy.
Still open: blog-mikepenz-dev.<account>.workers.dev serves the whole site publicly, and there is no WAF rate limit.
Know your plan: webhook secrets, the Workers CPU limit and the cost model all depend on it. And keep secrets out of URLs where you can, and out of logs where you cannot. Both the webhook key and the preview token here travel in query strings.
That is the series: Storyblok with an MCP to write it, Astro on Workers to render it, a signed token for the preview, a migration checked by diffs, and an edge cache that purges when I publish.