---
title: "The Cloudflare Workers Cache API is per-colo — the lesson that became warmup.rocks — warmup.rocks"
description: "Running a real Astro + EmDash site on Cloudflare Workers taught us three hard cache lessons: the Cache API is colo-local, it ignores stale-while-revalidate, and every deploy starts cold. Here's what we measured — and what we built because of it."
canonical: https://warmup.rocks/blog/cloudflare-workers-cache-api
---

Cloudflare Workers

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

July 2026 · 9 min read · [← All posts](https://warmup.rocks/blog/)

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](https://warmup.rocks/blog/astro-cache-warming) on Cloudflare Workers**, content managed in [EmDash](https://github.com/emdash-cms/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](https://developers.cloudflare.com/workers/runtime-apis/cache/)** 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](https://warmup.rocks/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](https://warmup.rocks/blog/time-to-first-byte) 45 ms in Frankfurt, 700 ms in Singapore. (Run the [global TTFB test](https://warmup.rocks/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](https://warmup.rocks/cache-checker): `HIT` vs `UPDATING` vs `EXPIRED` in [cf-cache-status](https://warmup.rocks/blog/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](https://warmup.rocks/docs/deploy-hooks); the multi-country crawler became [warmup.rocks](https://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](https://warmup.rocks/docs/deploy-hooks) 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](https://warmup.rocks/cache-checker) / [TTFB](https://warmup.rocks/ttfb-test)) show what the rest of the world gets.
