A stale response can preserve access to a public manual during an origin outage. It can also preserve an obsolete price, revoked permission, or misleading status. Availability is not automatically more important than correctness. Choose the information that may remain usable, the conditions that permit reuse, and the maximum tolerable age before enabling this behavior.

The trade is availability over freshness

Expiration and eviction are different events. An expired response can remain stored for conditional validation or permitted stale delivery. But a cold or evicted cache has no old body to return. Stale directives cannot create one, and retaining bytes does not by itself authorize reuse. The selected response must still match the request, including relevant Vary dimensions and any access boundary.

How it is signaled

Stale-while-revalidate permits a bounded stale response while validation is attempted without blocking that response. Stale-if-error instead permits using a stale response when the attempted origin interaction encounters an eligible error. These are different triggers. Neither extension mandates storage or guarantees that every cache implements the feature. A response arriving after the allowed window cannot rely on the earlier permission indefinitely.

For max-age=30, stale-if-error=60, the extra sixty seconds begin after freshness ends, not after each failed attempt. The response can be a candidate for error-time reuse for roughly ninety seconds of total age. RFC 5861 identifies errors that would produce 500, 502, 503, or 504. Authentication failures and missing resources must not casually be relabeled as availability failures to hide them behind an old success response.

When it breaks

Resetting Age after every failed revalidation turns a bounded exception into unlimited stale delivery. So does replacing the entry’s stored timestamp while keeping the old body. Preserve age on failure; only a legitimate new response or successful validation supplies the basis for updating freshness. Background validation is usually demand-triggered, so an idle cache does not necessarily refresh itself before traffic returns.

Read-only or idempotent does not mean safe to serve stale. A GET can reveal permissions or prices whose age matters. RFC 9111 prohibits stale reuse when an applicable directive such as no-cache or must-revalidate forbids it. Avoid contradictory policies and test the deployed cache’s treatment of restrictions. The old Warning-field advice in RFC 5861 is superseded by RFC 9111, which obsoletes that field.

Observe stale responses separately from fresh hits. Record total age, the failure category, and whether validation succeeded without logging secrets. A healthy HTTP success rate can conceal a failing origin if every response is old. Recovery requires verifying actual replacement or successful validation, not merely noticing that the origin process has started again.

Check it quickly

Use Node.js and npm; this was run with Node 26.0.0 and http-cache-semantics 4.2.0. Install the pinned package, then execute the JavaScript with NODE_PATH="$stale_lab/node_modules" node. This is a deterministic policy-and-body-selection experiment with synthetic responses and a controlled clock. It makes no requests and causes no outage; only installation needs network access.

stale_lab=$(mktemp -d)
npm install --prefix "$stale_lab" --ignore-scripts --no-audit --no-fund --package-lock=false http-cache-semantics@4.2.0
const assert = require("node:assert/strict");
const CachePolicy = require("http-cache-semantics");

const start = Date.parse("2026-09-07T00:00:00Z");
let clock = start;
class TestPolicy extends CachePolicy {
  now() { return clock; }
}
const request = { url: "https://example.test/manual", method: "GET", headers: {} };
const policy = new TestPolicy(request, { status: 200, headers: {
  "cache-control": "public, max-age=30, stale-if-error=60",
  etag: '"revision-a"', age: "0",
} });
const oldBody = Buffer.from("manual revision A");
clock = start + 35000;
const decision = policy.evaluateRequest(request);
assert.equal(decision.response, undefined);
assert.equal(decision.revalidation.synchronous, true);
assert.equal(decision.revalidation.headers["if-none-match"], '"revision-a"');
console.log("age 35: validate before using stale-if-error");

for (const status of [500, 502, 503, 504]) {
  const result = policy.revalidatedPolicy(request, { status, headers: { "cache-control": "no-store" } });
  assert.equal(result.modified, false);
  assert.equal(result.policy, policy);
  assert.equal(result.policy.responseHeaders().age, "35");
  const body = result.modified ? Buffer.from("origin error") : oldBody;
  assert.equal(body.toString(), "manual revision A");
  console.log(status, "uses old body without resetting Age");
}
const denied = policy.revalidatedPolicy(request, { status: 403, headers: { "cache-control": "no-store" } });
assert.equal(denied.modified, true);
assert.equal(denied.policy.storable(), false);
console.log("403 does not use the stale success response");

clock = start + 89000;
assert.equal(policy.revalidatedPolicy(request, { status: 503, headers: {} }).modified, false);
clock = start + 91000;
const expired = policy.revalidatedPolicy(request, { status: 503, headers: { "cache-control": "no-store" } });
assert.equal(expired.modified, true);
assert.equal(expired.policy.storable(), false);
console.log("age 89: stale allowed; age 91: error must be surfaced");

const recovered = policy.revalidatedPolicy(request, { status: 200, headers: {
  "cache-control": "public, max-age=30, stale-if-error=60",
  etag: '"revision-b"', age: "0",
} });
assert.equal(recovered.modified, true);
assert.equal(recovered.policy.age(), 0);
assert.equal(recovered.policy.satisfiesWithoutRevalidation(request), true);
const newBody = Buffer.from("manual revision B");
const delivered = recovered.modified ? newBody : oldBody;
assert.equal(delivered.toString(), "manual revision B");
console.log("recovery 200: replace the body and start the new policy");

At age thirty-five the normal decision requires synchronous validation; stale-if-error alone does not authorize an immediate stale hit. Each eligible simulated error then keeps revision A and its original policy. Outgoing Age remains thirty-five rather than resetting to zero. The 403 branch does not reuse that success body and its no-store response is ineligible for storage.

The same old entry survives an eligible error at age eighty-nine but not ninety-one. Repeated failures have not extended the window. A subsequent successful 200 with revision B produces a fresh policy and selects the new body. The fixture verifies both metadata decisions and which bytes the caller retains or replaces.

The library does not fetch, retry, schedule background work, or retain bodies for the caller. These assertions cover one matching warm entry and this nonconflicting policy, not network timeout handling, all directive combinations, or a complete CDN. Deployment acceptance still needs cold-cache, exhausted-window, and recovery tests through the real path, alongside evidence that stale delivery stays within the resource’s correctness requirements.

Further reading

Sources