How-to › Make calls that survive failure

How to serve stale API responses and revalidate in the background#

Add stale-while-revalidate and stale-if-error to a read endpoint so a shared cache hides origin latency and short outages, inside a window that ends.

Audience
API producer
Level
intermediate
Topic
Cache API responses
Verified

Read first: Choose Cache-Control values for private and shared caches

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

ApproachWhen it fitsWhat it costs youWhen 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 answerYou steer staleness only through headers, and each CDN documents its own exceptionsThe window should depend on logic a header cannot carry, such as backend health
TanStack Query staleTimeBrowser apps calling your API, each choosing how stale it can standIt helps only the app that set it, so other clients and the origin gain nothingSeveral kinds of client call the endpoint, or the origin needs shielding
Varnish graceYou run Varnish and want the stale window to depend on backend healthVCL to write and test, and a cache tier of your own to operateA 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.

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

  1. 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

  2. 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

  3. The arithmetic comes from the RFC itself. Its example sets max-age and stale-while-revalidate both 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.