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. As of 0.37.0 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() is the missing third piece: it 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, Cloudflare Workers, and Vercel Edge.
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.
Each response carries an X-Cache marker — HIT, MISS, or STALE — plus an Age header on a hit, so caches and clients can observe the outcome.
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.
Security notes
- Credentialed and per-user responses are never shared by default: anything carrying
Set-CookieorCache-Control: private | no-store | no-cacheis skipped — 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. - Use
varyHeaders(or a customkeyGenerator) to partition the cache whenever the response depends on a request header such asAccept-Language.