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.
Understand the division of responsibility
Section titled “Understand the division of responsibility”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=60That 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.
Let the middleware collect dependencies
Section titled “Let the middleware collect dependencies”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.
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:
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.
Read the three response outcomes
Section titled “Read the three response outcomes”| 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.
Stay within the header budget
Section titled “Stay within the header budget”The React Router adapter defaults to L3_BUDGET_DEFAULT:
maxBytes: 16 * 1024for the comma-joined header value;maxTags: 1000collected 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.
Account for immutable responses
Section titled “Account for immutable responses”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.
Know the remaining boundary
Section titled “Know the remaining boundary”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.