# Edge Caching with Stale-While-Revalidate: Fast and Fresh at the CDN

URL: https://www.devopsness.com/blog/edge-caching-stale-while-revalidate
Author: Kiril Urbonas
Published: 2026-06-21
Updated: 2026-10-31
Categories: Cloud
Tags: Cdn, Edge, Caching, Cloudflare, Performance

Summary: The cache-control header most teams under-use. How stale-while-revalidate and stale-if-error turn a CDN from a freshness liability into a latency and resilience win, with the gotchas.

---
# Edge Caching with Stale-While-Revalidate: Fast and Fresh at the CDN

`stale-while-revalidate` lets a CDN serve an expired copy instantly while it refetches from origin in the background. Send `Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400` and no visitor waits on origin at the TTL boundary, and a 5xx from origin keeps serving the cached page for a day.

> [!VERIFIED]
> RFC 5861 and RFC 9111, Cloudflare Cache-Control docs, Amazon CloudFront expiration docs, against the rfc-editor.org texts and vendor developer docs. Checked October 2026.

Most teams pick one extreme: `no-cache` everything (slow, origin-bound) or cache aggressively and fight stale-content bugs. These two directives, defined in RFC 5861, let you stop choosing. It is the highest-value header change we know of for a content site.

## The problem with binary caching

A plain `max-age=60` works like this: for 60 seconds the edge serves the cached copy. At second 61, the next visitor waits for a full origin round-trip while the cache refills. That one visitor pays for everyone else's freshness, and under bursty traffic it shows up as a latency spike at every TTL boundary, multiplied by every edge location that expires independently.

## stale-while-revalidate: serve stale, refresh in the background

```
Cache-Control: public, max-age=60, stale-while-revalidate=600
```

Fresh for 60 seconds. For up to 600 seconds after that, the cache may serve the stale copy immediately and revalidate asynchronously. RFC 5861 describes exactly this: the response is served stale "while an asynchronous validation" runs, hiding the latency from the user. The visitor who hits the expired object gets the old content at edge speed; the next visitor gets the refreshed copy.

Two properties are easy to miss. Revalidation only happens when a request arrives, so a page nobody visits for 11 minutes simply expires and the next hit goes to origin. And the stale window is per edge location: a low-traffic region can still pay the miss.

## stale-if-error: resilience for one directive

```
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
```

`stale-if-error=86400` says: if revalidation fails, keep serving the stale copy for up to a day instead of propagating the error. CloudFront's docs apply it when the origin is unreachable or returns a status between 500 and 600. Cloudflare's docs trigger it on origin 5xx only and say a 404 does not count. For marketing pages and docs, that turns a short origin outage into something visitors never see.

## How the big CDNs actually honor it

The RFC says "may". Each CDN adds its own rules, and a few of them silently disable the behavior:

| CDN | Honors stale-while-revalidate | Honors stale-if-error | Gotcha that turns it off |
|---|---|---|---|
| Cloudflare | Yes, revalidates in background; requests during refresh show `UPDATING` | Yes, origin 5xx only | Adding `s-maxage` disables SWR; Always Online ignores both; Workers Cache API unsupported |
| Amazon CloudFront | Yes, asynchronous fetch | Yes, origin unreachable or 5xx | Capped by the cache behavior's Maximum TTL |
| Browser cache | Varies by browser (see MDN compatibility table) | Varies; MDN treats 500, 502, 503, 504 as errors | Applies to the private cache, separate from the CDN |

The Cloudflare `s-maxage` rule is the one that bites. Its docs say that with `s-maxage` present a shared cache must revalidate before serving stale, so `public, s-maxage=60, stale-while-revalidate=600` gives you no background refresh there. If you need separate browser and edge TTLs on Cloudflare, set the edge TTL with a Cache Rule instead. Values must be integers; Cloudflare says a fractional `stale-while-revalidate` is ignored and may bypass the cache.

## Verify it from the command line

Check the header and the cache status yourself rather than trusting the config:

```
$ curl -sI https://www.example.com/docs/ | grep -iE 'cache-control|cf-cache-status|age|x-cache'
cache-control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
cf-cache-status: HIT
age: 41
```

Request again after `age` passes 60. On Cloudflare, expect `cf-cache-status: UPDATING` or `STALE` while the refresh runs, then `HIT` with a small `age`. On CloudFront, look at `x-cache: Hit from cloudfront` and the `age` header. If you only ever see `MISS` or `DYNAMIC`, the response is not cacheable at all, usually because of `Set-Cookie`, `private`, or a missing cache rule.

## The cache key determines correctness

None of this is safe if the cache key is wrong. The key must include every input that changes the response, and nothing else:

- **Vary on what matters.** If you serve different content by `Accept-Language` or auth state, that must be in the key, or one visitor gets another's page.
- **Strip what doesn't.** Marketing parameters such as `utm_*` and `fbclid` fragment the cache into near-zero hit rate. Exclude them from the key.
- **Never cache authenticated responses on a shared key.** Cookie-bearing responses need `private` or a per-user key. The classic CDN incident is caching a logged-in page and serving it to the world.

## Purge is the freshness escape hatch

A long stale window widens the time stale content can be served, so for content that must change now (a price, a corrected article, a legal takedown) TTL is not enough. Pair long stale windows with event-driven purge from your publish hook. On Cloudflare:

```
$ curl https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
    -d '{"files": ["https://www.example.com/article/123"]}'
```

Cloudflare notes that a 200 with `success: true` means the purge was accepted, not completed; confirm with a request that shows `cf-cache-status: MISS`. If your cache key includes headers, the purge request must send the same header values. TTL handles routine freshness; purge handles urgent freshness. For the wider tradeoffs, see [CDN cache invalidation strategies](/blog/cdn-cache-invalidation-strategies).

## A config to start from

```
# Static-ish HTML: docs, marketing, blog
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400

# Fingerprinted assets (hashed filenames)
Cache-Control: public, max-age=31536000, immutable

# Per-user responses
Cache-Control: private, no-cache
```

**Good fits:** marketing pages, docs, blog content, product listings that tolerate seconds of staleness, API responses for slow-changing reference data.

**Bad fits:** per-user dashboards, anything where stale data is a correctness or compliance problem (account balances, inventory at checkout), and responses that set cookies.

## Common failure modes

- **Header present, no stale serving on Cloudflare.** You also sent `s-maxage`, or Always Online is on. Remove one.
- **`stale-if-error` never kicks in.** The origin returned a 404 or a 200 error page, not a 5xx, or you also sent `no-cache` or `must-revalidate`, which Cloudflare says override it.
- **Stale content outlives the window on CloudFront.** It doesn't: CloudFront serves stale for the lesser of the directive and the behavior's Maximum TTL, so a short Maximum TTL silently caps you.
- **A user sees someone else's account page.** The cache key ignored a cookie or auth header. Mark it `private` and purge.

## Where this breaks

Stale-while-revalidate assumes serving slightly old content is harmless. For prices at checkout, stock levels, auth state, or anything regulated, it isn't, and you should not cache those on a shared key at all. It also does little for long-tail pages that get one hit an hour, because revalidation needs traffic to trigger. And it does not replace application caching: the [cache-aside, read-through and write-through patterns](/blog/caching-patterns-read-write-through-cache-aside) still matter for what the origin does on a miss.

## Questions people ask

### What does stale-while-revalidate do?

It lets a cache keep serving a response for a set number of seconds after `max-age` expires, while it fetches a fresh copy in the background. With `max-age=60, stale-while-revalidate=600`, requests between 60 and 660 seconds get the cached copy instantly and trigger an asynchronous refresh. The directive comes from RFC 5861, and both Cloudflare and CloudFront honor it.

### Does s-maxage work with stale-while-revalidate?

On Cloudflare, no. Its docs state that `s-maxage` disables stale-while-revalidate, because RFC 9111 says a shared cache must not serve stale content without revalidating when `s-maxage` is present. Use `max-age` with the stale directives, and set a separate edge TTL with a Cache Rule if you need it. CloudFront uses `s-maxage` for its TTL and documents SWR independently.

### What is the difference between stale-while-revalidate and stale-if-error?

`stale-while-revalidate` covers the normal case: the copy expired, so serve it now and refresh in the background. `stale-if-error` covers failure: revalidation hit an error, so keep serving the old copy for up to the given seconds. CloudFront counts an unreachable origin or a 5xx as an error; Cloudflare counts 5xx only, not 404.

## The call we'd make

Default every shareable HTML route to `public, max-age=60, stale-while-revalidate=600, stale-if-error=86400`, fingerprint and `immutable` your assets, and mark anything per-user `private`. Get the cache key right first, wire purge into your publish path, and check with `curl -sI` that your CDN is actually serving stale. If you are on Cloudflare, leave `s-maxage` out of these headers.

## Sources

- [RFC 5861: HTTP Cache-Control Extensions for Stale Content](https://www.rfc-editor.org/rfc/rfc5861.html)
- [Cloudflare Docs: Origin Cache Control](https://developers.cloudflare.com/cache/concepts/cache-control/)
- [Amazon CloudFront Developer Guide: Manage how long content stays in the cache](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html)
- [MDN: Cache-Control header reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control)

