Your rates endpoint is cached for ten seconds, and the first caller after each expiry waits for the origin’s full response time while the cache fetches a fresh copy. When the origin restarts, every caller whose entry has expired gets a 502 for as long as the restart takes. The data they were refused was ten seconds old and still correct.
What you get
You will end up with a read endpoint that a cache answers from a stale copy while it refreshes, with an outage window that ends and a counter that sees it. This is for you if a cache fronts data that may be seconds old.
Short answer
Send Cache-Control: max-age=10, stale-while-revalidate=30, stale-if-error=120 on a read endpoint.
A cache that honors RFC 5861 answers from the stale copy and refreshes it in the background, and
keeps answering for two minutes if the origin fails. Leave out s-maxage, must-revalidate, and
no-cache, which forbid stale answers, and alert on stale hits so the cache cannot hide an outage.
You will need
Node 22 or later, and a read endpoint that already sends max-age and an ETag. Choosing
Cache-Control values for private and shared caches is the page before this one in the series.
Verified 2026-10-15 against Node 22.22.0 and http-cache-semantics 4.3.0. Both directives come from
RFC 5861, and the stale hits are reported in the
Cache-Status field of RFC 9211.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| RFC 5861 directives at a CDN (Cloudflare, Fastly, CloudFront) | A CDN fronts the endpoint and every caller should get the stale answer | You steer staleness only through headers, and each CDN documents its own exceptions | The window should depend on logic a header cannot carry, such as backend health |
TanStack Query staleTime | Browser apps calling your API, each choosing how stale it can stand | It helps only the app that set it, so other clients and the origin gain nothing | Several kinds of client call the endpoint, or the origin needs shielding |
| Varnish grace | You run Varnish and want the stale window to depend on backend health | VCL to write and test, and a cache tier of your own to operate | A hosted CDN already fronts the API |
A CDN gives every caller the benefit, and you steer it only with the header, subject to each
vendor’s exceptions. Cloudflare
ignores both directives while Always Online is enabled.
Fastly serves an origin’s 503
unless your VCL selects the stale copy, and
CloudFront
caps both windows at the distribution’s maximum TTL.
Varnish reads its grace period from
stale-while-revalidate, and VCL can stretch it while a backend is sick.2 staleTime costs
nothing to adopt and protects only the app that set it.
Bound both windows in one header
Each number answers a different question. max-age=10 says how long the copy is fresh.
stale-while-revalidate=30 says how long past that a cache may answer from the copy while it
refreshes in the background. stale-if-error=120 says how long past freshness the copy may cover
for an origin that is failing.
export const RATES_POLICY = 'public, max-age=10, stale-while-revalidate=30, stale-if-error=120'
RFC 5861 asks you to pick the total staleness you can tolerate. With these numbers a caller can see rates up to 40 seconds old during normal running, and 130 seconds old during an outage.3
Watch a stale hit go out before the refresh lands
No Varnish or CDN runs inside the sample, so it models one. The freshness arithmetic and the
stale-while-revalidate window come from
http-cache-semantics in its
shared-cache mode, and a clock the demo moves stands in for time. This is the stale path in
get:
if (!revalidation.synchronous && mayServeStale(entry)) {
stats.staleWhileRevalidate++
refreshInBackground(path, request, revalidation.headers, entry)
return reply(entry, `hit; ttl=${ttl(entry)}; detail=stale-while-revalidate`)
}
refreshInBackground keeps one refresh per entry, so a hundred stale hits cost the origin one
request. Varnish in grace mode works the
same way: it schedules a backend fetch only when none is already running for the object. The demo
warms the cache, publishes version 2 at the origin, holds
the origin’s reply, and reads at 12 seconds.
node demo.mjs
Cache-Control: public, max-age=10, stale-while-revalidate=30, stale-if-error=120
t=0 200 v1 ModelCache; fwd=uri-miss; stored
t=5 200 v1 ModelCache; hit; ttl=5
t=12 200 v1 ModelCache; hit; ttl=-2; detail=stale-while-revalidate
refresh at the origin: arrived, still open, and the client already has v1
t=13 200 v2 ModelCache; hit; ttl=9
The read at 12 seconds was answered from the stale copy while the origin was still holding the
refresh, so the caller never waited for it. The negative ttl is how RFC 9211 marks a stale hit.
One second later the refreshed copy is fresh again, with nine seconds left.
Stop the origin and let the window run out
The same demo then stops the origin and reads on. At 30 seconds the copy is inside
stale-while-revalidate, so it is served, and the caller never sees the background refresh fail.
At 60 seconds the window has passed, the cache tries the origin, the connection is refused, and
stale-if-error answers instead.
node demo.mjs
origin stopped
t=30 200 v2 ModelCache; hit; ttl=-8; detail=stale-while-revalidate
t=60 200 v2 ModelCache; hit; ttl=-38; detail=stale-if-error
uptime check through the cache: 200, direct to the origin: connection refused
t=150 504 v- ModelCache; fwd=stale; detail=origin-unreachable
stats {"staleWhileRevalidate":2,"staleIfError":1,"errorsSurfaced":1,"refreshFailures":1}
At 150 seconds the copy is 138 seconds old, past max-age plus stale-if-error, and the outage
reaches the caller as a 504. That is the window doing its job: it ends. The line before it is the
pitfall. An uptime check that requests the API through the cache reported 200 for an origin that
was refusing connections.
Alert on stale answers, not on uptime
Count stale answers where the cache reports them, and page on staleIfError rising, because that
counter only moves while the origin is failing. In Cache-Status, a stale answer carries a negative
ttl. Cloudflare’s cache status values
split the same cases. UPDATING marks a stale answer during a background refresh, STALE one sent
because the origin could not be reached, and EXPIRED a copy refreshed from the origin before the
answer went out. Point the uptime check at the origin directly, or at a path the cache bypasses.
Keep s-maxage out of the header
RFC 9111 forbids a stale answer when
the stored response says no-cache, must-revalidate, proxy-revalidate, or s-maxage, because
s-maxage carries the semantics of proxy-revalidate.
Cloudflare follows it and answers
EXPIRED where you expected UPDATING. http-cache-semantics 4.3.0, the library under the model,
does not, so the model adds the check.
node demo.mjs
public, s-maxage=10, stale-while-revalidate=30, read at t=12
http-cache-semantics 4.3.0 alone: serve stale, refresh in the background
this cache, RFC 9111 section 4.2.4: ModelCache; fwd=stale; fwd-status=304
To give the edge a different lifetime from browsers, send max-age with the stale directives and
set the edge’s lifetime in the CDN. Cloudflare’s own workaround is an Edge Cache TTL rule, and
Fastly reads a separate
Surrogate-Control header that it strips before the response leaves.
Check it worked
The test holds the origin, sends five stale reads at once, and asserts that all five were answered
while the one refresh was still open. It then runs the outage timeline, a 503 in place of a refused
connection, and the s-maxage and must-revalidate cases.
node --test swr.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 316.888178
When it goes wrong
Cloudflare never reports UPDATING for the route. The response carries s-maxage,
must-revalidate, or no-cache, or Always Online is enabled on the zone. Remove the directive,
or switch Always Online off.
An outage lasted ten minutes without paging anyone. stale-if-error covered it, and the health
check read through the cache. Alert on the stale-if-error count, and make the check reach the
origin.
A Fastly service returns the origin’s 503 with a stale copy in its cache. Fastly serves stale
automatically only when a health check has marked the origin sick. For a 503 that arrives, or an
origin that cannot be reached, VCL has to select the stale copy with deliver_stale.
When not to do this
Do not serve stale data that a decision depends on. A balance, a stock level at checkout, or a
permission check that is 40 seconds old is a bug, and no-cache with an ETag is the policy for
those.
Do not set stale-if-error longer than you are willing to let an outage go unnoticed. A day of cover is
a day of callers reading yesterday’s rates with a 200 status. If nobody acts on the alert for
stale answers, callers see the outage only when the window ends.
Do not use staleTime in one browser app to protect the origin. It changes how often that app
fetches and does nothing for scripts, agents, or other apps, so the shield belongs in the shared
cache.
Do not add stale directives to a per-user response and expect a CDN to use them. A private
response is never stored by a shared cache, so there is no stale copy at the edge to serve.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and http-cache-semantics 4.3.0. Every output block is what the command preceding it printed. The cache is a model built on http-cache-semantics, not Varnish or a CDN: Cloudflare, Fastly, CloudFront, and Varnish behavior is quoted from their documentation, not measured.
Footnotes
-
RFC 5861 is an Informational document, an independent submission by Mark Nottingham dated May 2010, and all three CDN providers in the table implement it. It expects a stale response to carry a warning header, as HTTP then required. RFC 9111, which Nottingham co-edited twelve years later, obsoleted the Warning field and recorded why in its changes: in practice, Warning was not added by caches or intermediaries. RFC 9211, also his and from the same month, gives a stale hit somewhere to be reported, as a negative
ttl. ↩︎ Back to text -
Varnish starts every object with ten seconds of grace, its
default_grace, whether or not the origin asked for any. An origin that has never heard of RFC 5861 is served stale for ten seconds anyway, provided another request is already fetching a fresh copy. ↩︎ Back to text -
The arithmetic comes from the RFC itself. Its example sets
max-ageandstale-while-revalidateboth to 600 and observes, in section 3.1, that the server must then tolerate the response being served from cache for up to 20 minutes. Both numbers are the server’s own, so the 20 minutes is a tolerance the server set for itself. ↩︎ Back to text