Skip to content
Sponsor

Cache HTTP responses

Emit safe Cache-Tag headers from React Router renders without sharing private content.

Astilba Cache can connect value-cache dependencies to a shared HTTP cache. During a React Router request, the middleware records every Cache entry the render consumes, checks whether all of those entries are public, and emits their user tags as a Cache-Tag response header when the result is eligible.

The adapter answers one question: is it safe to associate this response with the dependencies Cache observed? It does not decide that the response should be shared.

Responsibility Owner
Set public, s-maxage, or another shared-cache directive Your application or framework response policy
Record Cache hits and fills used by the render Astilba Cache and the React Router middleware
Reject a response that consumed non-public or unreadable scope The middleware
Assemble an eligible Cache-Tag header The middleware
Purge the CDN after delete() Not implemented in the current source

The middleware never writes public or s-maxage. If your application sets no Cache-Control, it fills in private. To opt an eligible route into shared caching, set the final response policy yourself, for example:

Cache-Control: public, s-maxage=60

That opt-in remains subject to the middleware’s safety gate. If the render later consumes a private dependency, the middleware overwrites the policy with Cache-Control: private and removes Cache-Tag.

Register cacheMiddleware() at the React Router root, then use the request Cache from cacheContext. The middleware opens one render collector before loaders run and commits it after next() returns.

This setup targets Cloudflare Workers. In another runtime, omit waitUntil or pass that runtime’s equivalent lifecycle hook.

root.tsx (Cloudflare Workers)
import { waitUntil } from "cloudflare:workers"
import { cacheMiddleware } from "@astilba/cache/react-router"
import {
authenticatedUserContext,
authMiddleware,
} from "./auth.server"
import { cache } from "./cache.server"
export const middleware = [
authMiddleware,
cacheMiddleware({
cache,
waitUntil,
request: ({ context }) => {
const user = context.get(authenticatedUserContext)
return { userId: user.id, tenant: user.tenantId }
},
}),
]

Each served Cache hit contributes the entry’s complete stored tag set and stored scope evidence. Each successful fill contributes its final tag set and the scope the kernel resolved for it. Reserved per-key and per-namespace tags remain internal and are filtered before header emission.

Pass currentRequest() into each read so scope resolution sees the authenticated identity frame. Obtain the request Cache through cacheContext rather than importing the construction-time instance into route code:

routes/product.ts
import { t } from "@astilba/cache"
import {
cacheContext,
currentRequest,
} from "@astilba/cache/react-router"
import type { Route } from "./+types/product"
export async function loader({ context, params }: Route.LoaderArgs) {
const cache = context.get(cacheContext)
const productId = params.productId
const product = await cache.getOrSet({
key: `product:${productId}`,
request: currentRequest(),
scope: "public",
tags: [t`product:${productId}`],
factory: ({ signal }) => loadProduct(productId, signal),
})
return { product }
}

An explicit public scope is a claim that identity cannot change the value. The development request guard helps detect reads through ctx.request, but it cannot inspect values captured in closures. See Control cache sharing.

What the render consumed Response behavior
No managed dependency Preserve the application’s Cache-Control and Cache-Tag. If no cache policy exists, add Cache-Control: private.
Only eligible public dependencies Preserve the application’s cache policy, replace Cache-Tag with the collected user tags, and default to private only when no policy exists.
Any ineligible dependency Force Cache-Control: private, remove Cache-Tag, and emit one l3_ineligible telemetry event.

A response becomes ineligible for one of four reasons:

  • scope — a managed dependency was tenant- or principal-scoped;
  • scope-unreadable — a served entry did not expose readable scope metadata;
  • late-tag — a required tag arrived after header commit;
  • budget — the tag list exceeded its configured count or byte budget.

One unsafe dependency poisons a mixed render. Marking a tag l3: false never hides the associated entry’s scope: it can suppress a purge tag, but it cannot make private response content public.

Build a custom response adapter deliberately

Section titled “Build a custom response adapter deliberately”

The standalone collector is available to authors of another response adapter. It can declare a dependency that is not backed by a Cache entry and lets the adapter apply the returned decision itself:

const collector = cache.collect()
collector.dependsOn(t`site:theme`)
const emission = collector.commitHeaders({
maxBytes: 16 * 1024,
maxTags: 1000,
})

A bare RenderCollector.dependsOn() has no stored entry and therefore makes no scope claim. That absence is caller-trusted input, not evidence that the dependency is public. A custom adapter must independently establish that the rendered data is safe to share; when its scope is private or unknown, fail closed to a private response and omit its tags. Never use a bare declaration to hide a managed private dependency. Use { l3: false } to keep a verified render-only tag out of the emitted header and out of timing and budget checks.

The React Router middleware owns a different request-bound collector internally; it does not expose that collector for route code to mutate. Creating a standalone collector inside a React Router request does not add tags to the middleware’s eventual header. Use the automatic Cache hit/fill collection there, or write a custom adapter that declares and commits its own dependencies.

This is different from FactoryCtx.dependsOn(). Factory-declared tags persist into the cached entry and automatically carry that fill’s scope into the request collector. FactoryCtx.dependsOn(tag, { l3: false }) currently throws NotImplementedError because the stored entry format cannot preserve a per-tag emission flag for later hits.

The React Router adapter defaults to L3_BUDGET_DEFAULT:

  • maxBytes: 16 * 1024 for the comma-joined header value;
  • maxTags: 1000 collected tag occurrences.

Override the limits with cacheMiddleware({ l3Budget }). Budgeting counts every collected occurrence and the comma separators. After an eligible decision, the adapter deduplicates tags before writing the actual header, so it may emit fewer bytes than it measured but never more.

Cloudflare documents the current Cache-Tag syntax and aggregate response-header limit in Purge cache by cache-tags. Astilba’s defaults match the 16 KB and approximately 1,000-tag guidance; they do not implement the purge request itself.

Some platform responses have immutable header guards, including redirect and fetch-originated responses. If direct header mutation fails, the middleware rebuilds the response with the same body, status, status text, and existing headers, then applies the decided posture. Network-error responses that cannot be rebuilt are returned untouched; they expose no usable cache headers.

Safe dependency collection, scope demotion, budget enforcement, and Cache-Tag emission exist today. The configured Cdn capability, delete({ cdn }), and PurgeResult.edgePurged() do not yet drive or await a real CDN purge. Until that path exists, response tags are useful for inspection and future integration but are not end-to-end invalidation support.

Continue with React Router for complete middleware setup, Inspect cache behavior for ineligibility telemetry, or Implementation status for the preview ledger.