An RFC 9111 HTTP caching policy engine with the RFC 5861 stale-while-revalidate and stale-if-error extensions. It decides; the caller stores bodies and does the I/O.
import { CachePolicy } from 'http-cache-policy';
const req = { method: 'GET', url: 'https://example.com/a', headers: { accept: 'text/html' } };
const policy = new CachePolicy(req, { status: 200, headers: response.headers });
if (policy.storable()) {
const d = policy.evaluate(req); // later, for a new request
// d.action: 'reuse' | 'reuse-and-revalidate' | 'revalidate' | 'forward' | 'unsatisfiable'
// d.headers: the headers to serve (Age set, Date untouched)
}http-cache-semantics is the policy library behind got, make-fetch-happen and npm's registry client. Version 4.2.0 (last release 2025-05) has these defects, each reproduced below:
Varyvalues that mean*but are not the exact string*("*, *",", *","* ","*,") are reused across clients (upstream issue #57, "Vary: *reuse prohibition bypassed by whitespace/list forms and prototype field names").- A request
max-staleoverridesproxy-revalidate,s-maxageandno-cache(upstream issue #56, "max-stalerequest directive overrides shared-cache reuse prohibitions"). No-CaChEis not recognized.Expiresvalues in invalid formats are accepted as valid dates.Dateis rewritten when a stored response is served.- A fresh
must-revalidateresponse is not reused.
This package implements the same job from the RFC text and is measured against the http-tests/cache-tests suite.
Measured: node bench/repro.ts (after npm install in bench/compare), reuse of a stored response by a following request:
scenario want hcs 4.2.0 this package
Vary "*, *" false true (wrong) false
Vary ", *" false true (wrong) false
Vary "* " false true (wrong) false
Vary "*," false true (wrong) false
Vary "constructor" (prototype name) true true true
Cache-Control "max-age=100, No-CaChE" false true (wrong) false
Expires with UTC zone false true (wrong) false
Expires with 1-digit hour false true (wrong) false
Expires with dashes false true (wrong) false
Expires "0" false false false
must-revalidate, fresh (max-age=100) true false (wrong) true
max-stale on proxy-revalidate (shared) false true (wrong) false
max-stale on s-maxage (shared) false true (wrong) false
max-stale on must-revalidate false false false
max-stale on no-cache false true (wrong) false
max-stale on plain stale response (allowed) true true true
Date preserved on serve true false (wrong) true
The Vary: constructor row is not a defect: a header that is absent from both requests matches, in both libraries. The Set-Cookie part of issue #56 concerns a rule of http-cache-semantics that RFC 9111 (section 7.3) does not contain, so it does not apply here.
Measured: the cache-tests suite through a reverse proxy around each implementation (bench/run.sh, node bench/matrix.mjs). Required tests only; "of decided" is pass / (pass + fail), "of all" also counts tests that could not be run.
| Implementation | Required pass | fail | not run | of decided | of all | Optional pass / fail |
|---|---|---|---|---|---|---|
| http-cache-policy (this package) | 148 | 0 | 1 | 100.0% | 99.3% | 87 / 8 |
undici 8.11.2 interceptors.cache() |
125 | 12 | 12 | 91.2% | 83.9% | 65 / 25 |
| http-cache-semantics 4.2.0 | 113 | 16 | 20 | 87.6% | 75.8% | 56 / 36 |
best-before 0.2.1 (requireSharedDirective: false) |
116 | 15 | 18 | 88.5% | 77.9% | 61 / 29 |
| best-before 0.2.1 (defaults) | 30 | 12 | 107 | 71.4% | 20.1% | 11 / 16 |
| Negative control: forwards everything, caches nothing | 19 | 6 | 124 | 76.0% | 12.8% | 0 / 19 |
| Caddy (public result) | 116 | 4 | 29 | 96.7% | 77.9% | 38 / 54 |
| Apache httpd (public result) | 130 | 8 | 11 | 94.2% | 87.2% | 65 / 24 |
| Traffic Server (public result) | 132 | 14 | 3 | 90.4% | 88.6% | 70 / 20 |
| Varnish (public result) | 119 | 13 | 17 | 90.2% | 79.9% | 45 / 41 |
| Squid (public result) | 121 | 13 | 15 | 90.3% | 81.2% | 56 / 36 |
| Safari (public result) | 118 | 15 | 4 | 88.7% | 86.1% | 56 / 21 |
| Chrome (public result) | 117 | 18 | 2 | 86.7% | 85.4% | 56 / 20 |
| Firefox (public result) | 106 | 17 | 14 | 86.2% | 77.4% | 40 / 37 |
| HAProxy (public result) | 88 | 11 | 50 | 88.9% | 59.1% | 37 / 47 |
| nginx (public result) | 99 | 28 | 22 | 78.0% | 66.4% | 59 / 27 |
| Fastly (public result) | 96 | 32 | 21 | 75.0% | 64.4% | 49 / 34 |
Excluded: the cdn-cache-control and interim suites, which are not RFC 9111 policy. "Not run" counts tests whose prerequisite test failed and tests whose setup failed.
Notes on reading the table:
- The suite states that passing it "currently means nothing" by itself. Most required tests check that a cache does not reuse something, so a cache that stores nothing passes many of them; the negative-control row shows this, and why the "of all" column is there. Tests that need an earlier positive behavior to work are reported as not run when that behavior fails.
- The public results were produced by other people on other versions of the suite and are re-scored here with the suite's own
determineTestResultat the pinned commit. Only the shipped result files were used. - The proxy around this package is
bench/proxy.ts. It stores bodies, forwards requests, slicesRangerequests out of complete stored bodies and callsCachePolicyfor every caching decision. The comparison proxies are inbench/compare. - best-before is a layer over the Web Cache API. Node has no Cache API, so
bench/compare/proxy-best-before.mjssupplies a small in-memory store with theVarymatching that best-before delegates to the store. Its numbers include that store. @web-widget/shared-cacheis built on a fork ofhttp-cache-semantics.node bench/compare/fork-check.mjsshows the fork reusing responses forVary: "*, *",No-CaChEand anExpiresin UTC, so the package was not run through the suite.
npm install http-cache-policyNo runtime dependencies. Requires Node 20.6 or later.
const policy = new CachePolicy(request, { status, headers }, { shared: true });
if (policy.storable()) {
cache.set(key, { policy: policy.toObject(), body });
}toObject() returns JSON-serializable data. CachePolicy.fromObject() restores it. Record the times yourself for accurate ages:
new CachePolicy(request, response, { requestTime, responseTime }); // ms since the epochFilter your stored policies by satisfies(request) (URL, method and Vary), pick with CachePolicy.select() (most recent by Date), then ask evaluate():
const policy = CachePolicy.select(candidates, request);
const d = policy?.evaluate(request);
switch (d?.action) {
case 'reuse': return respond(d.status, d.headers, body);
case 'reuse-and-revalidate': respond(d.status, d.headers, body); revalidateInBackground(d.revalidation); break;
case 'revalidate': return sendConditional(d.revalidation);
case 'unsatisfiable': return respond(504); // only-if-cached
default: return forward(request);
}const rv = policy.revalidationRequest(request); // If-None-Match / If-Modified-Since
const r = policy.update({ status: 304, headers }, { requestTime, responseTime });
if (r.updated) store(r.policy); // merged per RFC 9111 3.2If r.updated is false the 304 does not identify the stored response. Fetch unconditionally. After a successful update, call policy.precondition(clientRequest) to see whether the client's own If-None-Match or If-Modified-Since earns a 304.
if (originFailed && policy.canServeStale({ staleIfError: true })) serveStored();
if (originUnreachable && policy.canServeStale({ disconnected: true })) serveStored();Neither ever overrides must-revalidate, no-cache, or (in a shared cache) proxy-revalidate and s-maxage.
for (const url of invalidates(request, response)) cache.delete(url);request is { method?, url?, headers? } and response is { status, headers? }. Headers may be a plain object, a Headers object or name/value pairs; repeated lines are combined with , as RFC 9110 5.3 allows.
| Option | Type | Default | Description |
|---|---|---|---|
shared |
boolean | true |
Shared cache rules. A private cache ignores s-maxage, may store private, and is not limited by Authorization. |
ignoreCargoCult |
boolean | false |
When pre-check and post-check are both present, ignore no-cache, no-store, must-revalidate and Expires. Deviates from RFC 9111. |
trustServerDate |
boolean | true |
When false, the response time replaces the Date header in age and Expires calculations. |
heuristicFraction |
number | 0.1 |
Fraction of the time since Last-Modified used as heuristic lifetime. |
maxHeuristicAge |
number | none | Cap on the heuristic lifetime in seconds. |
requestTime |
number | now | Clock value in ms when the request was sent. |
responseTime |
number | now | Clock value in ms when the response was received. |
Methods that depend on the clock take an optional now (ms or Date).
| Method | RFC 9111 | Returns |
|---|---|---|
storable() |
3, 3.5 | boolean |
satisfies(request) |
4, 4.1 | boolean: URL, method and Vary match; Vary: * never matches |
CachePolicy.select(policies, request) |
4, 4.1 | the most recent satisfying policy, or undefined |
freshness(now?) |
4.2 | { fresh, heuristic, lifetime, age, ttl, staleFor } (seconds) |
age(now?) |
4.2.3 | whole seconds |
canServeStale(allowance, now?) |
4.2.4, RFC 5861 | boolean; allowance is { maxStale?: number | true, staleIfError?: boolean | number, staleWhileRevalidate?: boolean, disconnected?: boolean } |
evaluate(request, now?) |
4, 5.2.1 | { action, reason, status?, headers?, revalidation? } |
responseHeaders(now?) |
3.2, 4, 4.2.3 | headers to serve; Age replaced, Date untouched |
notModifiedHeaders(now?) |
RFC 9110 15.4.5 | headers for a 304 |
precondition(request) |
4.3.2 | 'not-modified' or 'proceed' |
revalidationRequest(request?) |
4.3.1 | { method, url, headers, conditional } |
update(response, options?) |
4.3.4, 3.2 | { policy, updated, reason } for a 304 |
updateFromHead(response, options?) |
4.3.5 | { policy, updated, reason } for a HEAD 200 |
toObject() / CachePolicy.fromObject() |
JSON snapshot |
Getters: status, method, url, shared, etag, lastModified, date, cacheControl. hasValidators() reports whether revalidation can be conditional.
| Function | Description |
|---|---|
invalidates(request, response) |
URLs to invalidate after an unsafe request (RFC 9111 4.4): the target, plus same-origin Location and Content-Location. Empty for safe methods and error statuses. |
parseCacheControl(value) |
Parses RFC 9111 5.2. Returns an object with has(name), seconds(name) (number, null if invalid, undefined if absent) and fields(name) for the no-cache and private field lists. |
parseHttpDate(value, now?) |
Strict HTTP-date: IMF-fixdate, RFC 850 and asctime. Returns ms or undefined. |
formatHttpDate(ms) |
IMF-fixdate. |
parseVary(value) |
{ star, names, valid }. |
normalizeUrl(url) |
Fragment removed, host and scheme lower-cased, default port removed. |
- Storing follows RFC 9111 section 3 in order. Fields that must not be stored (
Connectionand the fields it lists, proxy fields, and the fields named by a qualifiedprivatein a shared cache or a qualifiedno-cache) are removed when the policy is created. - Freshness lifetime takes the first of
s-maxage(shared),max-age,ExpiresminusDate, then a heuristic. An invalidmax-ageorExpiresgives a lifetime of zero and suppresses the heuristic. Age is the larger of the apparent age and theAgeheader plus response delay, plus resident time. - Directives are split on commas outside quoted strings, compared case-insensitively, and accept token or quoted arguments. A repeated delta-seconds directive with conflicting values, or any invalid argument, makes that directive invalid.
- Dates are matched against the three grammars of RFC 9110 5.6.7 and nothing else. Day and month names and
GMTmatch case-insensitively (RFC 9111 4.2). Two-digit RFC 850 years resolve to the latest year not more than 50 years ahead. The day name is not checked against the date. Varymatching compares the request headers as they were stored against the new request after removing insignificant whitespace, combining field lines, folding case forAccept-LanguageandAccept-Encoding, and ignoring the order of their members. A*member, or a member that is not a field name, never matches.- Stale service is refused first, by
no-cache,must-revalidate, and in a shared cacheproxy-revalidateands-maxage. Only then domax-stale,stale-while-revalidate,stale-if-erroranddisconnectedcount. update()drops the storedAgeandDatebefore merging, so the freshened response is aged from the 304. It never takesContent-Length,Content-Range,Content-Encoding,Varyor hop-by-hop fields from a 304.
- Only GET and HEAD responses are stored.
POSTcaching is not implemented. - 206 responses are not stored and partial content is not combined. A caller can slice a complete stored body itself.
- Bodies, storage, eviction, request collapsing and background scheduling belong to the caller.
Varyselection among several stored variants is byDateonly. Ranking byqvalues is not implemented.- A 304 that carries no validator updates the stored response. RFC 9111 4.3.4 does not say to do so.
- When a request carries both
max-ageandmax-stale, a response older than themax-ageis revalidated. - The
Warningheader is not generated. - Request
Cache-Controlis read byevaluate().no-transformis not interpreted. ignoreCargoCultis a deliberate deviation from the RFC and is off by default.- Cache-tests results that remain (three runs of this package gave identical results):
- required
headers-store-Content-Lengthis not run: the suite's origin sends aContent-Lengththat does not match the body, the harness's Node HTTP client rejects it, and the policy never sees the message. - optional
method-POST: POST responses are not stored. - optional
vary-normalise-lang-select: needsq-value ranking among stored variants. - optional
conditional-lm-fresh-no-lm: the suite expects a 304 for anIf-Modified-Sinceolder thanDateon a response withoutLast-Modified. RFC 9111 4.3.2 says to compare withDate, which gives a 200. - optional
partial-store-partial-reuse-partial,-byterange,-absent,-suffixandpartial-store-partial-complete: store and combine 206 responses.
- required
Run: Node 24.20 (type-stripped tests and the built dist/). The CI workflow also builds and runs test/smoke.mjs on Node 20.6, 22 and 24; those runs have not happened yet. The package is ESM only and uses no Node APIs.
npm install
npx tsc --noEmit
npm test # unit, property and fuzz tests
npm run build && npm run test:dist
FUZZ_RUNS=1000000 FUZZ_SEED=7 npm test # longer property and fuzz run
npm run bench:fetch # downloads cache-tests at a pinned commit into .cache/ (BSD-3-Clause, never committed)
npm run bench # runs the suite through bench/proxy.ts
node bench/matrix.mjs # scores every result file, including the suite's public resultsThe comparison proxies need npm install inside bench/compare first.
MIT. See LICENSE.