Cloudflare Workers

The Workers Cache API is per-colo — the lesson that became warmup.rocks

July 2026 · 9 min read · ← All posts

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 — which is 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'tcache.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 — which 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 — and triggered it after every deploy, not just 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

  1. 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.
  2. 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.)
  3. 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.
  4. 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