Serving stale-if-error During an Origin Outage
After working through this guide you will be able to decide which responses may legitimately survive an origin failure, configure stale-if-error and the provider-specific equivalents that back it, label stale responses so monitoring and users can tell what they are looking at, and run a deliberate origin-failure drill that proves the whole arrangement works before a real incident tests it for you.
The scenario this guide addresses is narrow and specific: your origin is returning 502s, or refusing connections, or timing out — and your edge still holds a copy of the page from twenty minutes ago. Whether the user sees that copy or sees an error page is entirely a configuration decision you made in advance. Most teams discover they made the wrong one during the incident. The mechanics of the directive itself are covered in serving stale content with stale-while-revalidate; what follows is the operational side — the eligibility rules, the staleness budget you can actually defend, and the drill.
Key implementation objectives:
- Classify your routes into “safe to serve stale” and “must fail loudly”, and enforce the split in configuration rather than convention.
- Configure error-triggered stale serving on Cloudflare, Fastly, and CloudFront, including the failure conditions each platform recognizes.
- Annotate stale responses so dashboards, logs, and — where appropriate — the page itself reflect degraded state.
- Rehearse the outage with a controlled origin failure and measure what users would actually have received.
Which failures qualify, and which quietly do not
stale-if-error is defined against a specific set of conditions, and the gap between what engineers assume it covers and what it actually covers is where most disappointment lives.
It applies when a revalidation attempt produces a server-side failure: status 500, 502, 503, 504, and a 5xx from an intermediary. It also applies to transport-level failures — a connection refused, a TLS handshake that fails, a DNS resolution failure for the origin hostname, or a read timeout — because from the cache’s point of view none of those produced a usable response.
It does not apply to any 4xx. A 404 or 403 is, by definition, a valid answer from a working origin, so the cache stores or propagates it rather than treating it as an outage. This catches people out during deploys that briefly remove a route: the origin is up and confidently returning 404, stale-if-error never engages, and users see the 404.
The subtlest failure mode of all is an origin that returns 200 with an error body. An application that catches its own exception, renders “Something went wrong”, and returns it with a 200 has defeated every resilience mechanism downstream of it — the edge sees a perfectly good response, caches it, and now happily serves your error page for the full TTL. Before configuring anything else, make sure your origin’s failure path returns a 5xx status. It is the single highest-value change in this entire guide.
| Origin behaviour | Qualifies for stale-if-error | What the user gets |
|---|---|---|
502 / 503 / 504 |
Yes | Stale copy, 200 |
| Connection refused or reset | Yes | Stale copy, 200 |
| Read timeout past the origin timeout | Yes | Stale copy, 200 |
| TLS or origin DNS failure | Yes (provider dependent) | Stale copy, 200 |
404 after a bad deploy |
No | The 404 |
200 carrying an error page |
No | The error page, cached |
Object never cached (Set-Cookie, private) |
No | The raw error |
Setting a staleness budget you can defend
The window is a business decision wearing a technical costume. Someone will eventually ask why a customer saw a price that was four hours old, and “the default was 86400” is not an answer.
Set the budget per content class, not per site:
- Marketing and editorial pages, documentation, blog content. A day is defensible and often longer. Nothing about a week-old landing page misleads anyone, and the alternative is a branded error.
- Category listings, search results, non-price product metadata. An hour or two. Stock levels drift, but the page remains broadly truthful.
- Prices, inventory counts, availability. Minutes, or excluded entirely. If your terms of sale bind you to a displayed price, a stale price is a commercial exposure and not just a UX wrinkle.
- Authenticated, personalised, or transactional responses. Never. A dashboard, a cart, an order status, an account page — none of these should be in a shared cache at all, so none of them have a stale copy to serve. If you find one that does, the bug is upstream of
stale-if-error.
Write the budget down next to the routes it governs, so the value is reviewable:
/ stale-if-error=86400 marketing
/docs/* stale-if-error=86400 editorial
/collections/* stale-if-error=3600 listing, drifts slowly
/products/* stale-if-error=600 contains price
/api/v2/inventory/* stale-if-error=0 must fail loudly
/account/* (never cached) personalised
The pairing with stale-while-revalidate is worth stating plainly, because they are frequently conflated. stale-while-revalidate is a latency instrument: it fires when the origin is healthy and simply removes the revalidation wait. stale-if-error is a resilience instrument: it fires only when the revalidation failed. Set both, and set the error window much longer than the revalidate window, since one is measured against user patience and the other against incident duration:
Cache-Control: public, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400
Provider configuration
Cloudflare
Cloudflare honours stale-if-error from the origin Cache-Control on proxied hostnames, and layers two features that cover the cases the header cannot.
Always Online is a separate archive, not your live cache. Cloudflare crawls your pages and stores a copy; if the origin becomes completely unreachable it serves that archived version. Because it is crawled rather than captured from live traffic, it is coarser and older than a cached object — but it works even for URLs that were never cached and it survives a cache eviction. Treat it as the floor beneath stale-if-error, not a replacement.
Custom error caching and Cache Rules let you set the policy zone-wide rather than per response, which matters when part of your origin fleet emits headers you do not control. A Cache Rule that sets edge TTL and serve-stale behaviour applies regardless of what the backend sent.
For per-route control, a Worker gives you the explicit branch, including the ability to stamp the response so you can see it later:
export default {
async fetch(request, env, ctx) {
const cache = caches.default;
const key = new Request(request.url, request);
const cached = await cache.match(key);
let origin;
try {
origin = await fetch(request, { cf: { cacheTtl: 300 } });
} catch (err) {
origin = null; // connection level failure
}
const failed = !origin || origin.status >= 500;
if (failed && cached) {
const stale = new Response(cached.body, cached);
stale.headers.set("X-Served-Stale", "1");
stale.headers.set("X-Stale-Reason", origin ? String(origin.status) : "connect");
return stale; // degraded, but a 200
}
if (!origin) {
return new Response("origin unavailable", { status: 503 });
}
const fresh = new Response(origin.body, origin);
fresh.headers.set(
"Cache-Control",
"public, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400"
);
ctx.waitUntil(cache.put(key, fresh.clone()));
return fresh;
},
};
Fastly
Fastly has the most precise control, because the stale state machine is exposed directly in VCL. stale.exists tells you whether a servable stale copy is present, and deliver_stale returns it without further origin contact:
sub vcl_fetch {
# A resilience floor, independent of what the backend emitted.
set beresp.stale_if_error = 86400s;
set beresp.stale_while_revalidate = 60s;
# Backend errors: prefer a stale copy over propagating the failure.
if (beresp.status >= 500 && beresp.status < 600) {
if (stale.exists) {
set req.http.X-Stale-Reason = beresp.status;
return(deliver_stale);
}
set beresp.cacheable = false; # never cache the error itself
}
return(deliver);
}
sub vcl_error {
# Connection failures land here, not in vcl_fetch.
if (stale.exists) {
set req.http.X-Stale-Reason = "connect";
return(deliver_stale);
}
}
sub vcl_deliver {
if (req.http.X-Stale-Reason) {
set resp.http.X-Served-Stale = "1";
set resp.http.X-Stale-Reason = req.http.X-Stale-Reason;
}
}
Note the vcl_error half. A backend that refuses the connection never produces a beresp, so a vcl_fetch-only implementation covers 5xx responses and silently misses total origin death — which is the outage you actually cared about.
CloudFront
CloudFront does not implement stale-if-error natively, so resilience is assembled from origin failover and error caching. An origin group retries a second origin on the configured failure codes, and error caching stops a broken origin from being hammered:
resource "aws_cloudfront_distribution" "site" {
origin_group {
origin_id = "resilient"
failover_criteria {
status_codes = [500, 502, 503, 504]
}
member { origin_id = "primary" }
member { origin_id = "static-fallback" } # an S3 bucket of last-known-good pages
}
custom_error_response {
error_code = 502
error_caching_min_ttl = 10
response_code = 503
response_page_path = "/degraded.html"
}
}
The static-fallback member is the practical trick: point it at a bucket that your deploy pipeline populates with rendered copies of your highest-traffic pages. It is not the live cache, but it converts an outage from a gateway error into a slightly-old page, which is the same user outcome stale-if-error produces. Keep error_caching_min_ttl small and non-zero — long enough to absorb a burst, short enough that recovery is quick.
Marking stale responses so you can see them
A resilience feature that works invisibly is a resilience feature nobody can operate. If the edge silently serves day-old content for six hours, your error-rate dashboard stays green through an outage and nobody is paged.
Set three signals. First, a response header — X-Served-Stale: 1 with a reason — so any request can be classified from the outside and your log pipeline can count it. Second, an alert on that header’s rate, independent of status codes: a spike in stale serving is the incident signal, since status codes will all be 200. Third, for content where staleness is user-visible, a lightweight in-page notice. A small banner reading “showing cached data from HH:MM” is honest, costs nothing, and prevents the support tickets that come from someone acting on old numbers.
The related trap is that a failing background revalidation also produces no client-visible error. Alert on origin error rate measured at the origin, not on edge status codes, or an origin can be broken for the entire window and the first person to notice will be a user after it expires. Correlating those two views is easier when requests carry an identifier end to end, as described in tracing requests across the edge with correlation headers.
The origin-failure drill
Configuration you have not tested is a hypothesis. Run this drill in staging first and then, ideally, in production during a low-traffic window with the team watching. The four phases below are what you are trying to observe: a healthy baseline, a broken origin that users never see, the moment the budget runs out, and clean recovery.
1. Warm and confirm the object is cached.
URL=https://www.example.com/docs/getting-started
curl -sI "$URL" | grep -iE 'cf-cache-status|age|cache-control'
curl -sI "$URL" | grep -iE 'cf-cache-status|age'
# cf-cache-status: HIT
2. Let it go stale. Wait past s-maxage so the next request must revalidate. Without this step the edge answers from the fresh copy and the drill proves nothing.
sleep 310
3. Break the origin deliberately. Choose the failure mode you want to rehearse. A firewall drop rehearses a connection timeout; stopping the service rehearses connection refused; a return-503 rule rehearses a clean 5xx.
# connection refused
ssh origin 'sudo systemctl stop app'
# or: black-hole the edge's traffic to rehearse a timeout
ssh origin 'sudo iptables -I INPUT -p tcp --dport 443 -j DROP'
4. Request through the edge and read the headers.
curl -sI "$URL" | grep -iE 'http/|cf-cache-status|age|x-served-stale'
# HTTP/2 200
# cf-cache-status: HIT
# age: 3480
# x-served-stale: 1
A 200 with an age far beyond s-maxage and your stale marker present is the pass condition. A 502 or 503 is a fail, and the diagnosis is in the troubleshooting section below.
5. Measure the ceiling. Keep the origin down and keep requesting at intervals until stale serving stops. The elapsed time should match your configured window. This is the number to put in the runbook — it is how long you have to fix an origin before users notice.
6. Restore and confirm recovery.
ssh origin 'sudo iptables -D INPUT -p tcp --dport 443 -j DROP; sudo systemctl start app'
sleep 5
curl -sI "$URL" | grep -iE 'cf-cache-status|age|x-served-stale'
# age drops, x-served-stale absent
Record the result, including which failure modes you tested. A drill that only covered a clean 503 has not proven anything about connection refused, and those take different code paths on every platform.
Troubleshooting
The origin returns 200 with an error body
Symptom: during an outage users see a styled “temporarily unavailable” page and the edge cached it. Diagnose by requesting origin directly and checking the status line, not the body:
curl -so /dev/null -w '%{http_code}\n' https://origin.internal.example.com/docs/getting-started
If a broken origin answers 200, fix the application’s error handler to return 503 with a Retry-After, and add Cache-Control: no-store to that path. Until then no downstream cache can distinguish failure from success, and every resilience feature you configure is inert.
Nothing left to serve because the TTL was too short
Symptom: the drill returns 502 even though stale-if-error=86400 is set. The window starts when the object expires but it cannot resurrect an object the cache already evicted. With s-maxage=60 on a low-traffic path, the object may be gone from that point of presence long before the outage. Raise s-maxage so the object is retained, enable an upper-tier cache so fewer points of presence need their own copy — see enabling tiered cache and origin shield on Cloudflare — and remember that a hard purge deletes the very copy you were relying on. Prefer soft purge before risky deploys, as described in using cache tags for surrogate-key purging.
Vary fragmentation leaves no stale copy for that client
Symptom: some users get stale content during the outage and others get errors, apparently at random. The response varies on a header with high cardinality — Accept-Language, User-Agent, or a raw Accept-Encoding — so the cache holds many variants and any individual client may land on one that was never populated. Inspect the variant axis:
curl -sI "$URL" | grep -i '^vary'
# vary: accept-encoding, accept-language, user-agent <- too wide
Narrow Vary to the smallest set that is genuinely required, and normalise the values that remain so a hundred Accept-Language strings collapse into a handful of variants. Fewer, hotter variants means more of them survive to be served stale.
The response was never cacheable
If cf-cache-status reads BYPASS or DYNAMIC, no stale copy exists because no copy exists. The usual causes are a Set-Cookie on a cacheable route, a framework default of Cache-Control: private, or an authenticated route that should not be shared at all. Confirm which it is before touching stale directives — this is a cacheability problem, not a resilience one.
Frequently Asked Questions
Does stale-if-error cover a connection timeout, or only 5xx responses?
Both, on every major CDN — a refused connection, a reset, a TLS failure, or a read timeout are all treated as a failed revalidation. The implementation detail that catches people out is Fastly, where 5xx responses arrive in vcl_fetch but connection failures land in vcl_error, so a VCL that only handles the first case misses total origin death.
What is the longest stale-if-error window I should set? Long enough to outlast a realistic incident, bounded by how misleading the content becomes. A day is normal for editorial and documentation content; ten minutes or less is right for anything carrying a price or a stock level. Set it per content class rather than zone-wide, and record the reasoning next to the value.
Can I serve stale content to logged-in users? No. Authenticated and personalised responses should not be in a shared cache at all, so there is no stale copy to serve and any that exists is a cache-key bug worth fixing urgently. Serve those routes from origin and let them fail visibly; a stale dashboard is worse than an honest error.
Why did my 404 not trigger stale serving during a bad deploy?
Because a 404 is a valid answer from a healthy origin, not a failure, so it is never a qualifying condition. If a deploy can transiently remove routes, address it in the deploy process — ship the new origin before switching traffic — rather than expecting the cache to mask it.
How do I know stale serving actually happened during an incident?
Only if you marked it. Stamp a header such as X-Served-Stale: 1 on the degraded response, count it in your log pipeline, and alert on its rate — every status code will be 200, so nothing else in your dashboards will move. Pair that with an alert on origin error rate measured at the origin so a silently failing background revalidation cannot hide for the full window.