Why the menu is cached by version, not by time
The menu is read far more often than it changes. The bakery caches it under a key that names its version, so a new price is served from the first read after it is published and an old one is never served again, with no expiry to wait for and nothing to delete. This is how, what it leaves out, and what it costs.
A price, and when people see it
The seed’s menu has 24 items, and the country loaf costs $9.50 (crates/seed/src/menu.rs). A member of staff in the console sets it to $10.00. That is a draft: the Catalog object, one per sandbox, holds it as pending, and every reader still gets $9.50. Pressing Publish makes the drafts the menu, adds one to the menu’s version and records MenuPublished. From the next read on, everyone gets $10.00. The question for a cache is what happens between those two moments, and the bakery’s answer is that nothing does: there is no between.
What the Cache Lab shows, and what it cannot
The Cache Lab, as read on 2026-11-06, sends about 300 reads a second at a menu held in four cache nodes in front of a database with eight connections. Every entry has a time to live, and when the menu’s runs out the readers pile onto the database at once. Its switches are the usual answers: coalesce the misses, serve the stale copy while one fetch refreshes it, refresh early, jitter the expiry. Its writes either invalidate the entry or leave it, and it asks you to count the readers who get the old version until it expires.
The lab has no versioned keys and no ETag, and its cache is one place. Each of those is where the bakery differs, and the third is where the real platform differs from any simulation of one cache.
How a read works
GET /v1/sandboxes/…/menu (workers/api/src/http/menu.rs) first asks the Catalog for its version, a small read that the one object answering it makes strongly consistent: a publish that has committed is the version the next read sees. Then:
- If the browser sent
If-None-Matchwith the ETag of that version, such as"v2", the answer is 304 with no body, and nothing else is asked. - Otherwise the Worker looks in the Cache API under
<API origin>/_cache/menu/<sandbox>/v<version>, a URL no route answers. A hit is served withBakery-Cache: hit. - A miss asks the Catalog for the menu, stores it under the key of the version the body itself carries, in case a publish landed between the two reads, and serves it with
Bakery-Cache: miss.
Nothing to invalidate
Invalidation is the work of finding every copy of something that changed and removing it in time. With the version in the key there is no such work. After the publish, every read asks for v2; the entry for v1 is still in the cache and is never asked for again. Correctness does not depend on anything being deleted, so nothing has to be deleted on time, and a delete that fails costs nothing but space.
The bakery does delete the old entry, as housekeeping: the consumer of MenuPublished removes the previous version’s key (workers/api/src/queue.rs). Cloudflare’s documentation, read on 2026-11-06, says a delete purges only the data center the Worker ran in, so this frees the space where the consumer happened to run and nowhere else. Elsewhere the old entry ages out on its own: the code stores every entry with max-age=86400, a day, because no sandbox outlives the next 10:00 UTC. That is the only time-based rule in the menu’s cache, and it decides when space is freed, never what a reader sees.
The browser’s cache has a rule of its own
The menu’s answer carries Cache-Control: private, no-cache, the one route allowed something other than no-store (workers/api/src/http/headers.rs). Under RFC 9111 that lets the browser keep its copy, and only its own, but never use it without asking first. The menu screen sends the ETag back each time, and an unchanged menu costs a 304 and no body. That is a second cache with a second key: the Cache API is keyed by the version on the server, the browser’s by the ETag it holds, and both are the same number. A 304 carries no Bakery-Cache header, because the Cache API was never asked.
One miss for every data center
Cloudflare’s pages, read on 2026-11-06, are plain that the Cache API is local to a data center: what one stores does not replicate outside it, and the Cache API does not use tiered caching. So v2 misses once in every data center that serves the sandbox, not once in the world. A visitor’s requests usually reach one data center, so in practice it is one miss a publish. Two reads that miss at the same moment in one data center both build the menu, because nothing coalesces them; at one visitor’s traffic that is rare and cheap.
What the bakery does not use
No Workers Cache on bakery-web, the Worker that serves the storefront’s pages. As Cloudflare’s documentation stated it on 2026-11-06, requests to static assets are free, and with Workers Cache on, every request is billed at the standard rate, static assets included. CI fails any wrangler.jsonc that names the key (.github/workflows/ci.yml). The public /menu/ page, before a sandbox exists, is the seed’s menu built into the HTML, so it costs nothing to serve and has nothing to cache. And no expiry on the menu besides the day-long max-age: a time to live is how a cache guesses when its copy has gone stale, and a key that names the version never has to guess.
What it costs
- Every read still asks the Catalog. The version check is a Durable Object request on every read, hit or miss, and a miss adds the menu read. What the cache saves is that second request and the menu’s body, not the first; the first is the price of never serving an old menu. The bakery’s cost model (
docs/cost.md, as of 2026-11-04) prices Durable Object requests at 1,000,000 a month included on Paid, then $0.15 a million. - One miss a publish, per data center. Each publish makes the next read in each data center build the menu again.
- A new price nobody sees until it is published. A draft lives in the Catalog, and the menu a visitor reads is only ever a published version.
Try it
Open the menu with a sandbox: its “How this works” line names the version, the Bakery-Cache header and the ETag. Press Ask again and the answer is a 304. Sign in, change a price in the console, and ask again: still the old version, since a draft is not the menu. Publish it and ask once more: a new version, a new ETag and Bakery-Cache: miss, then a hit, or a 304, from then on.
Sources
- Cache, Cloudflare’s Workers documentation, read on 2026-11-06: the contents of the cache do not replicate outside the data center they were written in, and a delete purges only that data center.
- How the Cache works, Cloudflare’s Workers documentation, read on 2026-11-06: the Cache API is local to a data center and does not support tiered caching.
- Workers Cache, Cloudflare’s Workers documentation, read on 2026-11-06: with caching enabled, every request to the Worker is charged at the standard request rate, static asset requests included.
- Billing and Limitations, Cloudflare’s documentation on static assets, read on 2026-11-06: requests to static assets are free and unlimited.
- RFC 9110, HTTP Semantics, R. Fielding, M. Nottingham and J. Reschke, IETF, 2022, read on 2026-11-06: ETag, If-None-Match and 304 Not Modified.
- RFC 9111, HTTP Caching, R. Fielding, M. Nottingham and J. Reschke, IETF, 2022, read on 2026-11-06: what private and no-cache ask of a browser’s cache.
- The Cache Lab, the Cache Lab on ihaveasegway.com, read on 2026-11-06: a cached menu expiring under load, with coalescing, stale serving, early refresh and invalidation on write, simulated in your browser.
Back to the Cache principle on How it’s built, to the decision that chose it, ADR 0003, or to the lab.