Hello.

article

Fine-tuning the new blog, edge cache, purge by tag and real costs

2026-11-06 ยท Storyblok, Astro and Cloudflare Workers

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.

1 / 5

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:

a cached article, 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:

webhook, signed body for a/adb-tricks
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 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:

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

bash
openssl rand -hex 32

Put the output into your secret store as WEBHOOK_SECRET, without echoing it into a shared terminal or a chat.

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:

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

Security

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.