best-before
v0.2.1
Published
RFC 9111 HTTP caching semantics layered on the Web Cache API
Readme
best-before
best-before is a minimal RFC 9111 HTTP caching semantics layer on top of the Web Cache API.
Implementation
The goal of best-before is to layer RFC 9111 semantics on top of any runtime implementation that
can provide a Web Cache API store. Specifically, it accepts as store (caches.open(name)) and layers
Cache-Control client and server semantics, non-GET request remapping, stale-while-revalidate, and
stale-if-error behaviour on top. The goal is to be as accurate as possible, without adding all CDN-level
request collapsing and strictness guarantees.
- Runtime-agnostic across Web Cache API runtimes: Works against runtimes that support
Request,Response, andCache - Partial body hashing: Creates a max-1MB hash for request body caching
- Eager read, lazy fetch: Splits cache response from execution, to encourage racing cache retrieval then execute fetches lazily
Standards referenced:
- RFC 9111 (freshness/staleness),
- RFC 9211 (
Cache-Status), - RFC 9213 (targeted
cdn-cache-control), - RFC 5861 (
stale-if-error/stale-while-revalidate). - RFC 9110 (byte ranges and conditional requests).
The underlying implementation assumes that Vary can only be supported when Cache#match honours Vary.
If the Web Cache store does not support Vary, related functionality and invalidation won't work as expected.
Quick start
In a Cloudflare Worker, usage may involve invoking createHttpCache and passing it a Cache.
import { createHttpCache } from 'best-before';
export default {
async fetch(request, env, ctx) {
const cache = createHttpCache(await caches.open('http'));
const outcome = await cache.handle(
request,
// passthrough function calling the origin fetch the cache wraps
req => fetch(req),
// accepts ctx.waitUntil for deferred revalidation
ctx
);
// outcome.response may deliver a cached Response,
// while outcome.resolve falls through to the passthrough
return outcome.resolve();
},
};Behavior
Eager read, lazy fetch
handle() probes the cache and returns a CacheOutcome. The common path is to call resolve immediately ((await cache.handle(...).resolve()). resolve() handles resolution of uncached responses. When an ExecutionContext is passed, waitUntil is called when a passthrough is for background revalidation.
The response property is used when the cache is raced against another source, which may skip the origin. Read response first to see if the cache had an available response, without paying the cost of calling the origin via resolve(), then only call resolve() when the actual response is needed.
A stale-while-revalidate revalidation is deferred until either response is ready or resolve() is called.
// Probes the cache and may deliver a cached response early
const outcome = await cache.handle(request, callOrigin, ctx);
// `outcome.response` may be populated, but `tryAsset` takes precedence and the passthrough can be deferred
return (await tryAsset(request)) ?? (await outcome.resolve());Shared vs. private cache
shared (default true) selects RFC 9111 shared-cache semantics. Set shared: false for a
private / client-side cache (browser-like):
| | shared (default) | private |
| -------------------------------------------------------- | ---------------------- | -------------------------- |
| bare Cache-Control: max-age=… (no public/s-maxage) | not stored | cached & served |
| Expires (no Cache-Control) | cached & served | cached & served |
| s-maxage | governs freshness | ignored (shared-only) |
| private responses | not stored | stored & served |
| Authorization on request | not publicly cacheable | cached (single-user store) |
| proxy-revalidate | forces revalidation | ignored (shared-only) |
By default a shared cache treats a plain Cache-Control: max-age directive (without proxy-specific public, s-max-age, cdn-cache-control, must-revalidate, or proxy-revalidate) as private and does not store it. A server-set expiration, that isn't Cache-Control, such as Expires, Access-Control-Max-Age for CORS, or a permanent 301/308 redirect, is always shared-cached.
Setting requireSharedDirective: false bypasses this and treats unauthenticated GET and HEAD as cacheable by default.
Either way, s-maxage takes precedence, and Authorization or private responses are never shared-cached.
Isolating caches per user is your responsibility with shared: false, and user-specific responses will be cached. If a single shared store is used, one user's data may leak to another, if the store isn't partitioned. The CacheStore interface should be wrapped or partitioned before passing it to best-before with shared: false.
Conditional requests
On a fresh cache hit, if the client's If-None-Match or If-Modified-Since already match the stored entry, the library returns an empty 304 Not Modified (weak ETag comparison, * and comma-lists, If-None-Match taking precedence over If-Modified-Since; GET/HEAD only)
Origin revalidation normally strips conditional headers and fetches a complete 200 response. Set conditionalRevalidation: true to send the stored validators when revalidating a stale entry. This is an opt-in efficiency optimization. When the origin returns 304 Not Modified, the cache reuses the stored body and refreshes its metadata.
If a 304 includes a new cache policy, that policy must still allow the response to be stored. Directives omitted from the new policy are generally not carried over, so the cache removes the entry when the new policy is not storable. One exception applies to an entry already admitted to shared storage: if the 304 echoes bare no-cache, the entry remains eligible unless the 304 also specifies no-store or private.
Responses carrying no-cache are handled differently from ordinary stale entries. When an otherwise storable response has an ETag or Last-Modified validator, the cache retains it for at least one day, or for a longer explicitly configured lifetime. This retention period only keeps the response available for validation; it does not make the response fresh. The cache contacts the origin before every reuse and sends the validators even when conditionalRevalidation is disabled. Responses without validators are not retained.
In a shared cache, no-cache also requires public, s-maxage, must-revalidate, proxy-revalidate, or a CDN-specific cache policy. immutable and stale-serving extensions are not sufficient on their own. Bare shared no-cache is not retained, regardless of requireSharedDirective. A no-cache response to an authorized request is retained only when one of the qualifying directives allows shared storage. Private caches may retain bare validator-backed no-cache. Responses carrying no-store are never stored.
For GET and HEAD, the effective If-None-Match or If-Modified-Since may be passed to CacheStore.match so a fresh, verifiable native 304 can be used directly. A stale or unverifiable native 304 is looked up again without conditionals so the library retains the complete representation for freshness handling. Responses varying on either field are not stored.
Range requests
Single bytes ranges over eligible, complete cached 200 responses are served locally as 206; valid unsatisfied ranges produce 416. Bounded (bytes=0-99), open-ended (bytes=100-), and suffix (bytes=-500) forms are supported. Client preconditions are evaluated first, and If-Range uses strong validator semantics (unlike the weak ETag comparison used by If-None-Match).
An eligible stored response must have a reliable nonnegative Content-Length, an available body containing exactly those representation bytes, and no Content-Encoding other than identity. Multiple or malformed ranges, unknown units, encoded/unknown-length representations, and range misses are forwarded unchanged. 206 responses are never stored. An honored only-if-cached request returns 504 when the range cannot be served locally.
Stores retain complete responses. For a single range, the library lets CacheStore.match return a native 206; otherwise it slices the selected complete representation itself. A stale native slice or one rejected by If-Range is looked up again without Range so revalidation, full-response fallback, and stale fallback retain the complete body. If-Range is evaluated by the library and is not passed to the store. Responses with Vary: Range or Vary: If-Range are not stored. Multipart ranges, incomplete-response storage, and encoded-representation slicing are not supported.
Pure decision engine
The RFC 9111 decision logic is exported as standalone, pure-over-arguments functions, so you can build custom orchestration or use the library purely as a decision engine.
import {
computeStoreDecision,
computeCacheDecision,
parseCacheControl,
} from 'best-before';
// Should this origin response be stored, and with what Cache-Control?
const store = computeStoreDecision(request, response, { shared: true });
// Given a stored entry, is it a hit / stale / miss / timeout?
const decision = computeCacheDecision(
parseCacheControl(request.headers.get('cache-control')),
store.input,
age,
{ staleWhileRevalidate: true, shared: true }
);API Reference
createHttpCache(store: CacheStore, options?: HttpCacheOptions) => HttpCache
- Accepts a
CacheStore(the nativeCachefromcaches.open(name)works directly) - Parameters
options?: HttpCacheOptions: feature toggles (all default on-by-default; see below)
Returns an HttpCache that layers RFC 9111 freshness/staleness semantics over the store.
interface HttpCache
interface HttpCache {
handle(
request: Request,
passthrough: Passthrough,
ctx?: ExecutionCtx
): Promise<CacheOutcome>;
}handleprobes the store forrequestand returns aCacheOutcome. It never callspassthroughitself.- Parameters
passthrough: the origin fetch the cache wraps, run at most once and only when the origin is neededctx?: when given, background revalidations usectx.waitUntil
interface CacheOutcome
interface CacheOutcome {
readonly response: CacheResponse | undefined;
resolve(): Promise<CacheResponse>;
}response: the answer the cache has ready without the origin (a hit, an SWR-stale entry, a304, or anonly-if-cached504), orundefinedon a miss. Reading it never callspassthrough.resolve(): the response to serve, always. Returnsresponsewhen the cache can answer; otherwise runspassthroughand stores the result. Runs at most once.
class CacheResponse extends Response
A Response (return it directly) with two extra own properties:
- Properties
decision: CacheDecision: the freshness/staleness verdictcacheStatus: CacheStatus: an RFC 9211-shaped description of how the request was served
The library never emits a Cache-Status header, but once can be derived by formatting cacheStatus.
type Passthrough = (request: Request) => Promise<Response>
The origin fetch the cache wraps. Called by resolve() (or a deferred stale-while-revalidate revalidation)
with a request that may be rewritten
(conditional headers stripped, HEAD remapped to GET, the stored entry's validators added under conditionalRevalidation).
interface ExecutionCtx
interface ExecutionCtx {
waitUntil(promise: Promise<unknown>): void;
}interface CacheStore
interface CacheStore {
match(
request: Request,
options?: { ignoreMethod?: boolean }
): Promise<Response | undefined>;
put(request: Request, response: Response): Promise<void>;
delete(
request: Request,
options?: { ignoreVary?: boolean }
): Promise<boolean>;
}The minimal storage surface the library needs. The native Cache satisfies it structurally, so caches.open(name) can be passed directly.
Namespacing should be done by cache name (caches.open('http-v2')), not a key prefix.
Wrap the store to transform what's stored/read, instrument hits/puts, scope keys, attach Cache-Tag, or back it with an alternate store.
[!IMPORTANT] A from-scratch store must implement
Varymatching. The library stores theVaryheader and rejectsVary: *, but delegates secondary-key selection tomatch. It does not compare varied request headers itself. If yourCacheStoredoes not supportVary, it won't function.
match does not need to implement Range semantics. For responses eligible for local byte slicing, it must expose the same complete representation bytes counted by Content-Length; runtimes that transparently decode bodies while retaining encoded headers are not eligible.
interface HttpCacheOptions
The options accepted by createHttpCache — all optional.
| Option | Default | Description |
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| staleWhileRevalidate | true | Honor stale-while-revalidate (serve stale, revalidate in the background) |
| staleIfError | true | Honor stale-if-error (serve stale when the origin errors) |
| onlyIfCached | true | Honor the client's only-if-cached (a miss becomes a 504) |
| clientCacheBypass | true | Honor client cache-busting (no-cache, max-age=0, etc); set false to ignore it and protect the origin |
| shared | true | Shared cache (true) vs private/client-side cache (false) |
| requireSharedDirective | true | Require an explicit shared directive (public, s-maxage, etc) before shared-caching a bare max-age, treating it as private; Expires/CORS/301-308 stay shared-cached |
| cacheNonGetMethods | false | Cache non-GET/HEAD methods (POST, OPTIONS, etc) via method+body-hashed synthetic GET keys |
| conditionalRevalidation | false | Revalidate a stale entry with a conditional request instead of a full re-fetch |
The min-fresh and max-stale request directives are only partially honored (some clients, e.g. Chrome, don't send them)
computeStoreDecision(request: Request, response: ResponseLike, options: StoreDecisionOptions) => StoreDecision | null
Pure over (request, response). Accepts options:
shared(defaulttrue): shared vs private cache semanticsrequireSharedDirective(defaulttrue): shared cache only — treat a baremax-ageas private
Returns null when the response isn't storable, otherwise a StoreDecision of two Cache-Controls:
input and output.
interface StoreDecision {
readonly input: CacheControl;
readonly output: CacheControl;
}input: theCache-Controlthat governs how the stored entry is later evaluated (feed it tocomputeCacheDecisionasentry.cacheControl)output: the transformedCache-Controlpersisted onto the stored response
computeCacheDecision(client: CacheControl, cacheControl: CacheControl, age: number, options: CacheDecisionOptions) => CacheDecision
Pure over its arguments. client is the parsed request directives, cacheControl the stored entry's
governing directive (computeStoreDecision(...).input), and age its age in seconds. Accepts options:
staleWhileRevalidatestaleIfErroronlyIfCachedclientCacheBypassshared
Returns a CacheDecision verdict.
enum CacheDecision {
/** only-if-cached with no cached response, return 504 */
MISS_TIMEOUT,
/** a client directive forces revalidation */
MISS_REQUEST,
/** no cached response, origin consulted */
MISS,
/** fresh cached response served */
HIT,
/** request is not cacheable */
BYPASS,
/** origin errored and a stale entry may be served */
STALE_IF_ERROR,
/** stale entry served while revalidating in the background */
STALE_WHILE_REVALIDATE,
}parseCacheControl(headerValue: string | null) => CacheControl
- Accepts a raw
Cache-Controlheader value (ornull)
Returns a parsed CacheControl. Fault-tolerant: unknown directives are collected into unrecognized
and malformed values are skipped rather than throwing.
