Web Development

Workers Cache: The Hit Happens Before Your Auth Code Runs

Cloudflare's Workers Cache sits in front of the Worker, so a hit is served without your handler ever executing. Zero CPU is the selling point and also the whole problem: it is proof your authorization code did not run. Here's how we structure a cached multi-tenant app now.

KAKabir AnandLead Developer
8 min read
Editorial photograph of a long row of identical steel lockers with one door standing open

Seventy-one percent hit ratio, 38 ms at p50, and one catalogue page that showed a reseller in Lisbon the pricing tier belonging to a customer in Toronto. That was day two of a load test, not production, and the only reason we caught it is that the harness happened to run two tenant sessions concurrently instead of one.

The bug was not in the cache. It was in an assumption we had carried over from every previous Cloudflare project without ever writing it down: our Worker runs on every request, therefore the authorization check inside it runs on every request. Workers Cache makes that false, and nothing in the diff says so. The config change was four lines.

The hit happens before your code

For years the model was straightforward. The Worker ran, and then it decided what to do about caching — caches.default lived inside your fetch handler. You read the request, resolved the session, and only then looked for a stored response. The check always ran because the code always ran, and the cache was something your code operated rather than something that operated on your code.

Workers Cache inverts that. A tiered cache now sits in front of any Worker, on every plan, turned on with one block of Wrangler config. On a fresh hit the Worker does not execute — no isolate, no handler invocation, and CPU billing of zero. That zero is the headline number, and it is also the entire problem: it is measurable proof that your authorization code did not run.

Two paths, and only one of them runs your code

CACHE HITrequestedge cachekeyed by the requesthit — returns from hereyour Workernever executes · 0 CPU msresponseCACHE MISSrequestgatewayauthenticate · build keyrender entrypointno session, no secretscache writeresponse
The top lane is the one that gets skipped in review, because it has the fewest boxes. It is also the lane that serves the overwhelming majority of your traffic once the cache is warm.

Look at the top lane again. Every guard you wrote inside that Worker — the tenant scope, the entitlement lookup, the redirect on an expired session — is now conditional on a miss. On a cache that is doing its job, a miss is the exception. You have moved the most security-sensitive code in the application onto the least-travelled path in the system, and you did it in a pull request titled "enable edge caching".

Split the Worker before you cache one byte

The fix is not smarter cache headers. It is structural, and it is the one architectural decision this whole exercise really contains: a gateway entrypoint that always runs, and an inner entrypoint whose response would be safe to hand to a stranger. The gateway talks to the inner one over a service binding, and only the inner one's output is ever cached.

  • The gateway always runs. Session parsing, tenant resolution, entitlement lookup, rate limiting, redirects for expired auth. It sits above the cache, so none of it is ever skipped. It is also the only place in the system that is allowed to read a cookie or an Authorization header.
  • The inner entrypoint gets a key, not a session. It receives a normalised URL and nothing else — no cookies, no auth header, no client IP. Not by convention: the gateway constructs a fresh request object, so there is physically nothing for the renderer to accidentally read and then bake into a cached page.
  • The review test is one sentence. Would we be comfortable if this exact response were served to any other authenticated user who arrived at the same key? If the answer is no, the key is wrong — not the caching policy, the key.

That last one sounds like a slogan until you make it a checklist item. We added it to the PR template for two client apps in June, and in both cases it caught something within a fortnight: a draft-preview flag on one, a currency-formatted price block on the other. Neither would have been caught by a test that asks whether the page renders correctly, because in both cases the page rendered perfectly — for exactly one of the two people looking at it.

Flat vector illustration of a single guarded doorway separating a dark outer zone from a lighter inner zone holding identical rounded panels
One door that everybody walks through, and a room behind it that holds nothing worth stealing. That is the entire pattern.

The cache key is the permission set

Once the split exists, the key stops being a performance detail. It becomes the complete, explicit statement of who is allowed to see a given set of bytes. Everything that changes the output has to appear in it; anything that appears in it and does not change the output costs you hit ratio. That is a real tension, and the only safe way to resolve it is to enumerate, deny by default, and let hit ratio be the thing you tune afterwards.

src/gateway.ts
1// The gateway runs on every request. The renderer does not.
2// Anything that decides WHO you are belongs here, above the cache.
3export default {
4  async fetch(req: Request, env: Env) {
5    const session = await authenticate(req); // never cached, never skipped
6    const url = new URL(req.url);
7 
8    // One parameter per thing that changes the bytes. Deny by default:
9    // if it is not listed here, it must not reach the renderer.
10    const key = new URL(url.pathname, "https://render.internal");
11    key.searchParams.set("tenant", session.tenantId);
12    key.searchParams.set("grants", session.grants.sort().join("."));
13    key.searchParams.set("locale", normaliseLocale(req));
14    key.searchParams.set("currency", session.currency);
15    key.searchParams.set("device", isMobile(req) ? "m" : "d");
16 
17    // A fresh Request: no cookies, no auth header, nothing to leak.
18    return env.RENDERER.fetch(new Request(key, { method: "GET" }));
19  },
20};

Sorting the grants matters more than it looks — an unsorted array gives you a different key for the same permission set depending on the order your database returned rows, which is a hit-ratio leak that behaves differently in staging and production. And every value is built from the session, not from the request. If a caller can set it, a caller can use it to ask for somebody else's cache entry.

The variants people forget are not exotic. They are the ones added by a different team, months after the caching decision was made, in a change that had nothing to do with caching. Here is our current list, in the order we have actually been bitten:

  • 1Entitlement and plan — the same page with an upgrade prompt for one plan and a live feature panel for another. This is the one that produces a real leak rather than a cosmetic bug.
  • 2Currency and locale — and specifically the gap between them. A German-language page priced in dollars is a support ticket; a partner rate shown to a retail buyer is a commercial problem.
  • 3Feature-flag cohort — flags change output by definition. If your flag SDK evaluates inside the renderer, every experiment quietly poisons the cache with whichever variant happened to be rendered first.
  • 4Draft and preview state— the CMS preview cookie is the classic. Unkeyed, one editor’s preview of an unpublished page becomes the public page for everybody at that URL.
  • 5Device class — cheap to forget because it usually degrades gracefully, and worth listing anyway so that leaving it out is a decision rather than an oversight.
Isometric illustration of one platform tile fanning out into a receding grid of identical tiles with a few highlighted
Every dimension you add to the key multiplies the entries you have to fill before the cache pays for itself. Five binary variants on one page is thirty-two distinct objects.

What we measure before we call it cached

Enabling the cache takes an afternoon. Being able to say it is safe takes about a week, and it comes down to four measurements — not opinions, and not a design review.

  • 1A cross-tenant probe in CI. Two sessions, different tenants, identical URL, byte-compare the responses with the cache warm. This is the actual security test and it is roughly forty lines. Ours runs on every PR that touches the gateway or the key builder, which is why the Lisbon/Toronto bug cannot come back.
  • 2Hit ratio per key shape, not per route. Route-level numbers hide the problem. A route sitting at 80% overall can be one key shape at 97% and another at 4%, and that 4% is usually a dimension somebody added to the key that does not change the output at all.
  • 3Worker invocations and CPU milliseconds, before and after. This is the number the change was made for, and it is the one nobody records a baseline for. Take it a week early, on the same traffic shape, or you will spend the review arguing about the result instead of reading it.
  • 4Purge latency, measured. Publish a change, poll from three regions, record when the old bytes stop appearing. If that number is longer than your content team assumes, they will find out during a price change and not before.

None of this is an argument against Workers Cache. We run it on two client applications now and the savings are what was advertised — one of them dropped Worker invocations by a bit over 70% on catalogue traffic, and p50 on those pages roughly halved. The point is narrower: caching in front of the Worker moves a boundary that used to live inside your code out into a key that never appears in a stack trace. Treat that key as the authorization artefact it now is, review changes to it the way you review changes to a permission check, and the performance win is genuinely free. Skip that, and the first person to tell you the boundary moved will be a customer who saw something belonging to somebody else.

Keep Reading

More from the blog

Track Record

The Engineering Partner You Can Build On

Reliable software takes an experienced team that owns delivery end to end. Here’s the track record behind ours.

Book a Free Consultation
01

11+

Years Building Custom Software

02

320+

Projects Delivered Across Web, Mobile & AI

03

85%

Repeat Client Rate

04

12+

Countries Served Worldwide

Trusted by startups and enterprises worldwide

Work With Us

Let’s create
with purpose

Share your goals, timeline, and challenges — we’ll respond with clarity and next steps.

Ambitious ideas deserve thoughtful execution. Start the conversation and let’s define what success looks like.

Team

Acetrum

Est. 2015

4.9/5

Trusted by
top brands

Services interested in:

By submitting, I confirm I’ve read and agree with Privacy and Cookie Policies.

Newsletter

Signals worth
paying attention

No recycled headlines — just the patterns we’re seeing across real client work, distilled into one read a month.

A curated digest of practical thinking and real-world brand perspectives monthly.

No spam. Unsubscribe anytime.