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.
What the edge does when revalidation fails A decision path: a request arrives for an expired object, the edge attempts revalidation, and depending on whether the origin failure qualifies and whether a stale copy still exists within the window, the edge either serves the stale copy or propagates the error. Expired object request arrives Revalidate origin fetch 200 from origin store, serve fresh 5xx / timeout qualifying failure 4xx from origin not a failure Stale copy served, 200 inside the stale-if-error window no stale copy, or window elapsed, and the error reaches the user

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
Staleness budget by content class Four content classes shown as horizontal bars of increasing length: authenticated responses which are never served stale, price and inventory data at ten minutes, listings at one hour, and editorial content at twenty-four hours. How long may this class of response outlive its origin? Authenticated never cached Price, inventory 600s ceiling Listings, search 3600s Editorial, docs 86400s 10 minutes 1 hour 24 hours of origin downtime absorbed fail loudly instead

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.

Origin failure drill phases Two rows across four time phases: the origin state moves from healthy to stopped to still down to restored, while the edge response moves from a fresh hit to a marked stale two hundred, then to a five oh three once the window elapses, then back to a fresh miss and hit. Origin state What the user receives Healthy baseline warm-up Service stopped connection refused Still down budget exhausted Restored service started HIT, age 12 fresh copy 200, age 3480 X-Served-Stale: 1 503 reaches user runbook deadline MISS then HIT age resets t0 t0 + 6 min t0 + window recovery the gap between phase two and phase three is the time you have to fix the origin

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.

Back to Stale-While-Revalidate & Resilient Caching