Response caching
A hot read endpoint often renders the same response over and over while nothing has changed. Re-running the handler (and its database or upstream calls) each time is pure waste. The responseCache() middleware stores rendered response bodies and replays them for matching requests, so the handler is not invoked at all while a cached representation is fresh.
It completes (and does not overlap with) the two caching-adjacent helpers DaloyJS already ships. etag() answers conditional GETs with 304 Not Modified but still runs the handler to produce the body it hashes; compression() shrinks the bytes on the wire but caches nothing. responseCache() caches the body.
It is built-in and dependency-free, built on the Web-standard Request/Response, so it runs unchanged on Node, Bun, Deno, and Cloudflare Workers.
Key Recommendation for Response caching middleware
Use server-side response caching for public, high-read, and computationally expensive GET/HEAD endpoints. Credentialed requests bypass the cache by default. To cache personalized responses, identify the caller with principal() so each one gets its own entry instead of sharing yours.
When & Where to Use
- ✓Public, non-personalized read endpoints (e.g., product lists, public profiles, configuration feeds).
- ✓Handlers that perform expensive database operations, complex calculations, or third-party API fetches.
- ✓GET or HEAD endpoints with high request volumes where responses change infrequently.
- ✓Personalized reads, ONLY with a principal() that names the caller so the key partitions per user.
When & Where NOT to Use
- ✗Personalized, user-specific data without a principal(). The request bypasses the cache, so you gain nothing and should not reach for the middleware.
- ✗Mutative requests (POST, PUT, PATCH, DELETE) which perform side-effects.
- ✗Real-time data feeds (e.g., live stock prices, chat messages) where any latency is unacceptable.
- ✗Endpoints that carry high-entropy security tokens in headers or response bodies.
Quick start
Mount responseCache() ahead of the read routes whose rendered bodies are safe to reuse for a short window. By default only GET / HEAD responses with status 200 are cached.
One ordering rule: if the app also uses rateLimit() or loginThrottle(), register the limiter before the cache. A cache hit is returned from beforeHandle and ends the hook chain, so a limiter mounted behind the cache never counts the requests the cache serves and the declared budget is effectively unlimited. In production that order refuses to boot.
Each response the cache handles carries an X-Cache marker (HIT, MISS, or STALE), plus an Age header on a hit, so caches and clients can observe the outcome. A request that bypasses the cache entirely (a non-GET/HEAD method, an Authorization header, or a request Cache-Control: no-store) passes through unmarked.
How it works
For an eligible request the middleware derives a cache key and:
- Fresh hit: the stored response is served and the handler does not run (
X-Cache: HIT). - Stale hit within the SWR window (requires
revalidate): the stale response is served immediately (X-Cache: STALE) while a single, de-duplicated background refresh repopulates the cache. - Miss: the handler runs and a cacheable response is stored (
X-Cache: MISS).
Cache-Control orchestration
Freshness is derived from the response’s own Cache-Control when present (s-maxage wins over max-age), falling back to the configured ttlSeconds. Responses are never cached when they:
- carry
Cache-Control: no-store,private, orno-cache. - include a
Set-Cookieheader (per-user / credentialed responses must not be shared); - fail
cacheableStatus(default: only200), or - exceed
maxBodyBytes(1 MiB by default).
On the request side:
Cache-Control: no-storebypasses the cache entirely (no read, no write).Cache-Control: no-cachebypasses the read but still refreshes the stored entry. This is exactly what the background stale-while-revalidate refresh uses, which makes revalidation recursion-safe.
stale-while-revalidate
With staleWhileRevalidateSeconds plus a revalidate callback (typically wired to app.fetch), a stale-but-recent entry is served immediately while a single background refresh runs. The refresh request carries Cache-Control: no-cache so it bypasses the cached read and repopulates the entry without recursing.
Options
Pluggable stores
The default MemoryResponseCacheStore is process-local, perfect for tests and single-instance deployments. For a multi-instance or serverless fleet, supply a shared backend by implementing ResponseCacheStore. The contract mirrors SessionStore and the rate-limit store. Entries whose staleUntil is in the past should be treated as missing.
Cache key and cross-principal isolation
A shared response cache is only as safe as its key. Anything that varies the response but not the key becomes a cross-principal disclosure (CWE-524): the next caller of the same URL receives the previous caller's private body, with a perfectly normal-looking x-cache: HIT. DaloyJS is fail-closed on every principal dimension the framework can see.
cache key = [ tenant partition ] [ principal partition ] method + effective request URI + varyHeaders
│ │ │
│ │ └─ scheme + authority + path + query (RFC 9111 §4)
│ └─ principal(ctx), when supplied
└─ ctx.state.tenant, folded in automatically by tenancy()
+ [ secondary key ] ─── the request's values for the fields the
response's own Vary header names (RFC 9111 §4.1)
Authorization or Cookie present, and neither handled nor identified? → bypass the cache entirelyThe authority is part of the key
The key is built from the effective request URI (scheme, authority, path, and query) per RFC 9111 §4. One process serving several hostnames (vanity domains, subdomain-per-customer, staging alongside production) therefore never shares an entry across them. A key covering only path and query would silently mix them.
Credentials fail closed
Requests carrying Authorization or Cookie bypass the shared cache entirely (RFC 9111 §3.5). Cookie counts because a session cookie is the single most common way a response becomes private. A cache that only knew about Authorization would happily serve one logged-in user's page to the next visitor.
Rather than losing the cache on authenticated routes, name the caller with principal. The id is folded into the key, so each principal gets their own entry and hits still work:
A principal that returns null for a request that does carry credentials is treated as "cannot identify this caller", and the request bypasses the cache rather than sharing one anonymous entry among authenticated users. Declaring the credential in varyHeaders also counts as handling it, since its value then partitions the key by itself.
Declared variants are honoured
A response's own Vary header is the origin telling the cache which request headers its content depends on, and DaloyJS honours it as a secondary key (RFC 9111 §4.1) with no configuration. This matters because middleware you already mount emits Vary for you: cors() adds Vary: Origin alongside the reflected Access-Control-Allow-Origin, and compression() adds Vary: Accept-Encoding alongside Content-Encoding. A cache that ignored those would serve one caller's allowed origin (or their gzipped bytes) to the next.
Each distinct set of values is stored as its own variant, so several variants of one URL stay warm at the same time rather than evicting one another. A response carrying Vary: * declares itself unreusable and is never stored.
varyHeaders remains useful and is additive: it partitions before the handler runs, which is what you want when the response does not declare Vary itself but you know it depends on a header anyway.
Tenants partition automatically
When tenancy() has resolved a tenant for the request, that tenant is folded into the cache key with no wiring on your part, and the partition is applied around a custom keyGenerator too, so a hand-written generator cannot accidentally widen it. A caller that resolves to no tenant is kept in its own partition rather than sharing the resolved ones.
Ordering still matters, and it is enforced rather than merely documented: because the key is built in beforeHandle, a responseCache() mounted ahead of tenancy() would run before the tenant exists in ctx.state. In production that combination refuses to boot (see boot guards) instead of quietly serving one tenant's data to another. Register tenancy() first.
Access control is not order-sensitive
A cache hit returns a response from beforeHandle, which ends the hook chain. Any gate running in that same phase could therefore be skipped by a hit above it. That is why the network-identity gates (geoBlock(), ipRestriction(), botGuard(), autoBan() and ipReputation()) run in preBody, which always precedes beforeHandle. They hold whether you mount them above or below the cache. Authentication (bearerAuth(), basicAuth(), clientCertAuth()) runs in preBody for the same reason.
This matters for a hand-written gate: a custom guard in beforeHandle can be preempted by a cache hit mounted ahead of it. Put your own access-control checks in preBody too, or register them before the cache.
Other security notes
- Responses carrying
Set-CookieorCache-Control: private | no-store | no-cacheare never stored, the same skip posture asetag(). - Only
200 OKis cached unless you widencacheableStatus, so error pages do not poison the cache. - Stored bodies are capped by
maxBodyBytesto bound memory growth from large replies, andMemoryResponseCacheStoreis bounded on both entry count (maxEntries, default 10,000) and retained body bytes (maxBytes, default 64 MiB). Both limits are needed: expiry-based pruning alone cannot bound a burst of requests for distinct URLs, because every entry in it is unexpired for the whole TTL. - Use
varyHeaders(or a customkeyGenerator) to partition the cache whenever the response depends on a request header such asAccept-Languagewithout saying so inVary. - Hop-by-hop headers (
Connection,Transfer-Encoding,TE, …) and theX-Request-Idcorrelation id are stripped before an entry is stored, so a cached reply never replays another request's trace id or corrupts message framing. Add a custom correlation header toexcludeHeaders. - Partition components are length-prefixed, so a principal or tenant id containing the key delimiter cannot be crafted to collide with another partition (cache-key injection).