Configuration / Serving and Caching Settings

Page Cache Settings

Settings for the KV page tier, stale serving, the in-memory cache bins and the compiled plan tier.

The KV Page Tier

PAGE_KV is a cross-colo tier between the edge cache and the Durable Object, on for both plans whenever the namespace is bound. It still costs one Worker request, since the Worker has to run to consult it, so what it buys is latency and never serving ceiling. A missing binding always wins over PAGE_KV_ENABLED.

Free KV allows 1,000 writes a day for the whole account, and CONFIG_KV draws on the same allowance. So the object grants each page write: an answer that may be stored carries x-cfw-kv-grant, and the front worker stores nothing without it. The object counts its grants in its daily meter row and stops at KV_WRITES_PER_DAY, 800 on free. Past that, the site keeps serving from what it already stored and from the object. Only the primary grants, so replica lanes do not multiply the budget. /serve-stats reports pageKv.writesToday against pageKv.budget. Compiled plans share the namespace and carry no budget, so on free they are written only when PAGE_KV_ENABLED is 1.

Stored pages are keyed by site, generation and path, so a generation bump invalidates every one of them without enumerating or deleting anything. KV has no bulk delete, so a scheme needing one would be uninvalidatable in practice. PAGE_KV_TTL is therefore a floor on garbage, and no kind of freshness knob.

NEVER_STALE

Because the previous generation's entries are still in KV after a bump, a miss at generation N can be answered from N-1 or N-2 while the current one regenerates on the alarm chain. The response carries x-cfw-edge: STALE and x-cfw-stale-behind, and the regeneration is queued rather than run inline, so the visitor never waits for a render on a path that has been rendered before.

Two bounds apply together. The generation depth is 2, so a stale answer is at most two content changes behind; a 24 hour wall-clock bound on top of it stops an abandoned site serving last month's page.

A session-carrying response can never be served stale, and that is structural rather than a check: the stale read sits behind the same edgeWanted gate that requires a non-personalised response. On top of it, a built-in deny-list refuses /user, /admin, /cart, /checkout and the other paths where a one-change-old answer is wrong rather than merely old. NEVER_STALE is a comma-separated list of extra prefixes and is added to that list rather than replacing it, so a site cannot make its own login page staleable by configuring badly.

Consumers: staleAllowed() and readStalePage() in src/ops/page-store.ts.

MEMORY_CACHE_BINS

A cache bin named here is held in the interpreter's memory for the life of the incarnation instead of in the tenant's SQLite. pib_run performs no request shutdown, so a class static survives from one Worker invocation to the next, which is what makes this a real tier here and a test double on an ordinary PHP host.

The default is dynamic_page_cache and menu. MEMORY_CACHE_BINS=none turns the tier off, and a variable set to an empty string takes the default, the same as an unset one.

In a measured re-render after a tag invalidation, the bin in memory charged 4 rows where the bin in SQL charged 8, for a byte-identical page (workerd, shipping pack, the two arms differing by this variable only). This is a tag-invalidated re-render, not the realRender class. That class, a first render of /user/login with both bins empty, charges 1 row in memory and 8 in SQL (tests/integration/rows-per-fill-audit.spec.ts, 2026-11-02). Rows written is the meter that bounds regeneration.

After an interpreter drop the in-memory bin is empty, and the next render rebuilds it. Across a drop, the in-memory setting charged 2 rows against 6 for the SQL bin. The cost is isolate memory for the entries held and CPU on the rebuilding render.

Bin names are bare, without the cache_ prefix: MEMORY_CACHE_BINS=dynamic_page_cache,render.

An entry is validated against the cache-tag checksum on every read, the same test the database backend applies, so an invalidation that arrives as replicated SQL rather than as a PHP call still refuses it. That is the property a read replica depends on and the reason this is not core's MemoryBackend.

MEMORY_CACHE_MAX_ITEMS bounds each bin. It is a count rather than a byte budget because measuring bytes means serialising the value, which is the cost moving the bin out of SQL removes; the isolate's own 112 MiB recycle is the backstop underneath it.

Both are on KV_OVERRIDABLE. The value is read when settings.php is composed, which happens on every boot because the pack's filesystem is remounted each time, so a change takes effect on the next interpreter boot rather than needing a redeploy. Source: memoryCacheBins() in src/do/levers.ts, and CfwCacheBackendFactory in the drupflare module.

EDGE_PLAN

The compiled-plan tier in the front worker. On by default on both plans; 0 opts out, and an empty value is treated as unset. It is the only tier that answers an authenticated page without a Durable Object hop, because it is the only one whose key is per-visitor: the shared page tiers are keyed without a user and must never hold one.

Three renders of the same page compile a plan; the first is discarded, because it warms Drupal's asset library cache and its stylesheet list differs from every later render. The plan must reproduce both remaining renders byte for byte, hold no slot it cannot generate, and survive a re-diff against a freshly generated one. Anything else falls through to an ordinary render.

The key is the site, the generation, the path and the visitor's role set. Two properties make that narrow key safe. A plan is compiled only from renders by two DIFFERENT sessions of the role set, so anything constant for one user and different for another shows up as a region the compiler cannot name and the plan is refused; and it is served to a session only once that session's own render has agreed with it.

A site with a single editor never produces a second session, so a plan is also compiled under a key that names the session and is served to nobody else. It is held in isolate memory and never mirrored to PAGE_KV. The shared plan is preferred whenever one exists.

Slots are the values that differ per request or per visitor: form_build_id, a view's DOM id, and the session CSRF token in the logout link. The first two are generated fresh; the token is substituted from what the visitor's own last render carried, so a plan holding one refuses to run for a visitor whose token this isolate has not seen.

A response that rotates the session is never compiled: a Set-Cookie that sets a value the request did not already hold. Drupal re-sends the session cookie unchanged on every request when session.cookie_lifetime is non-zero (its default is 2000000), so a Set-Cookie header alone does not mean rotation.

Served responses carry x-cfw-cache: PLAN and x-cfw-plan: mem, private or kv; everything else reports on x-cfw-plan why it did not.

x-cfw-plan is the edge plan and nothing else. The Cloudflare account plan is x-cfw-account-plan. x-cfw-v carries the header contract version, and a client checks it before reading either.

An isolate serves against the last generation it learned from the object, and stops after 10 seconds without re-learning one. That is the staleness bound, and it is the same two-window lag the shared generation pointer already carries. A write by the visitor moves the generation on its own response, so their own save is visible immediately.

Compiled plans are mirrored into PAGE_KV so a second isolate does not have to compile them again. That read is bounded at 8 ms: a KV key a colo already holds answers in 4-5 ms, and one it has not seen costs 46-140 ms whether it exists or not, which is more than the object hop it would replace.

Measured on a deployed paid worker, one session, one 100,748-byte authenticated page:

where the answer came from ms to response cpuTimeMs wallTimeMs
EDGE_PLAN=0, the object hop 10 median, 7 min (n=40) 1 18
a plan in this isolate's memory 0 median and max, n=57 0 1