The Workers Cache API is per-colo: the lesson that became warmup.rocks
warmup.rocks didn't start as a product idea. It started as a performance problem on one of our own sites: a content platform built with Astro on Cloudflare Workers, content managed in EmDash (the open-source, Git-based CMS we also contribute to). Server-rendered at the edge, cached at the edge. On paper, the fastest architecture there is. In practice, we kept seeing 600 ms+ TTFBs from regions we cared about. This article is the field notes: three things the docs technically tell you about Workers caching, but whose consequences you only feel on a real site.
Lesson 1: caches.default is colo-local. Really local.
The Workers Cache API looks like a global key-value store for responses:
const cache = caches.default;
let res = await cache.match(request);
if (!res) {
res = await render(request);
ctx.waitUntil(cache.put(request, res.clone()));
}
But cache.put() writes to the cache of the data center your
Worker is running in, and nowhere else. No replication, no tiered cache
(Tiered Cache applies to fetch()-proxied traffic, not the Cache API). With
330+ Cloudflare data centers, a
cache.put() in Zurich warms exactly 1 of them. Every colo your visitors
hit re-renders the page once per TTL. That's fine for your top pages in your top
markets and quietly terrible for everything else. We could see it directly: the same
URL, TTFB 45 ms in Frankfurt, 700 ms
in Singapore. (Run the global TTFB test against your own
Worker and you'll see your version of that table.)
Lesson 2: the Cache API ignores stale-while-revalidate
Our Cache-Control strategy assumed SWR semantics: serve stale instantly, refresh in
the background. The edge cache (Cache Rules / Cloudflare-CDN-Cache-Control)
honors that. The Cache API doesn't: cache.match() simply
returns nothing once max-age is up, and your Worker does a full synchronous
re-render. We also measured a subtler variant at the edge layer: with
s-maxage set, entries revalidated synchronously (status
EXPIRED) instead of in the background (UPDATING). The
practical rule we landed on: keep s-maxage out of responses you want SWR
behavior for, and treat the Cache API as a plain TTL cache with zero grace. Anything
needing SWR belongs in the zone-level edge cache with
Cloudflare-CDN-Cache-Control: max-age=86400, stale-while-revalidate=604800.
You can verify which behavior you're getting from outside with the
cache checker: HIT vs UPDATING
vs EXPIRED in cf-cache-status tells
the whole story.
Lesson 3: every deploy starts the world cold
The one that actually hurt: Workers Cache API entries are namespaced per deployed version (unless you opt into cross-version caching where available). Deploy a new version and your Worker cache is empty in every colo at once. Combined with lesson 1, a Tuesday-afternoon deploy meant: the next visitor in each of 330+ locations got a full SSR render. For a site that deploys several times a day, "edge-cached" was true maybe 70% of the time in big markets and almost never in small ones.
What we built: a warmer. Then a product.
The first fix was a cron Worker that fetched our sitemap every hour and requested
every URL. That warmed exactly one colo (see lesson 1) and taught us the real
requirement: warming has to happen from where the visitors are, not
from where your script runs. So we built a warmer that requests every sitemap URL from
dozens of countries, so the warm copies land in the actual edge caches that serve real
traffic. We triggered it after every deploy as well as on a schedule. Hit ratios per
location made the effect measurable: the Singapore row went from
MISS / 700 ms to HIT / 60 ms, permanently.
That tool is what you're reading the blog of. The deploy trigger became the deploy hook + GitHub Action; the multi-country crawler became warmup.rocks.
Recommendations if you're on Workers today
- Prefer the zone edge cache over the Cache API for HTML. Set
Cloudflare-CDN-Cache-Control(with SWR) from your framework or a Cache Rule: it gets tiered cache, SWR and purge-by-tag. Use the Cache API only for colo-local memoization where its limits don't bite. - Purge by Cache-Tag on publish instead of purging everything; your CMS knows which pages changed. (EmDash setups can do this from a publish hook.)
- Assume deploys mean cold. Warm right after each deploy. A one-line GitHub Action in the deploy workflow closes the gap before the first visitor finds it.
- Measure from outside, per location. Your own colo always looks warm to you. Six-location checks (cache / TTFB) show what the rest of the world gets.
Running on Workers? Deploys are your cold-start events.
warmup.rocks warms your site from 90+ edge locations on a schedule and on every deploy, built on Workers lessons we learned the slow way, so you don't have to.
Start your 7-day free trial