Stale-While-Revalidate for Static HTML

Static HTML is usually served with Cache-Control: max-age=0, must-revalidate: every request asks the origin (or the CDN's copy) whether the page changed. It is safe — readers never see an old page — but each view pays a revalidation round trip, and CDN edges far from where the page was last fetched go back to origin. For content that changes a few times a day, that is a lot of checking for very little change.

stale-while-revalidate offers a middle ground. A response stays fresh for a short max-age, and after that the cache may keep serving it for a further window while it fetches a new copy in the background. Readers get cached-speed responses; content still updates within a bounded time. This guide applies it to a 1,200-page documentation site, measures TTFB and origin load, and shows how to keep deploys visible quickly. It is part of CDN Caching Rules for SSGs.

Prerequisites

  • Hashed assets on a long immutable lifetime, so HTML is the only thing that needs this treatment — see Cache Busting with Content-Hashed Filenames.
  • A CDN that honours stale-while-revalidate for edge caching, or supports a separate CDN-specific header.
  • An agreed answer to "how stale may a page be after a deploy?" — minutes, not hours, for most sites.

How It Behaves

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

  • For the first 60 seconds after a copy is cached, requests are served from cache with no revalidation.
  • Between 60 and 660 seconds, requests are still served from cache immediately, and the cache triggers one background revalidation; the next request gets the fresh copy.
  • After 660 seconds without a request, the copy is too stale and the next request waits for the origin.
Lifecycle of a cached HTML response A timeline starting when a page is cached. From 0 to 60 seconds it is fresh and served directly. From 60 to 660 seconds it is stale but served immediately while a background revalidation fetches a new copy. After 660 seconds the next request must wait for the origin. max-age=60, stale-while-revalidate=600 fresh stale: serve now, refresh in background wait for origin 0 s 60 s 660 s A popular page is almost never in the red zone: some reader triggers the refresh first
For busy pages the reader never waits; for rarely visited pages the behaviour degrades gracefully to a normal fetch.

Step 1: Set the Headers

Browser caching and edge caching often deserve different windows: browsers should not keep HTML long (a reader who refreshes expects the latest page), while edges can hold it longer if you can purge them on deploy. Many CDNs support a separate header for the edge:

# _headers (Cloudflare Pages / Netlify syntax)
/*
  Cache-Control: public, max-age=0, must-revalidate
  CDN-Cache-Control: public, max-age=60, stale-while-revalidate=600

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

Here browsers always revalidate (cheaply, against the nearby edge), and edges serve HTML for up to 11 minutes with background refresh. Where the CDN does not support a separate header, put stale-while-revalidate in Cache-Control with a small max-age, accepting that browsers may also serve a page up to that old. Host-specific details are in Setting Cache-Control Headers on Cloudflare Pages.

Step 2: Make Deploys Visible Quickly

The trade-off with any HTML caching is staleness after a deploy. Two options bound it:

  • Accept the window. With a 60-second max-age and 600-second SWR, a busy page shows new content within about a minute (the first request after 60 s triggers a refresh), and a quiet page within 11 minutes. For documentation and blogs that is usually fine.
  • Purge HTML on deploy. Purge by path prefix or cache tag after each deploy so edges fetch fresh HTML immediately, and use the longer windows purely for performance between deploys. See Purging the CDN Cache After a Static Deploy.

The site here purges HTML on deploy and keeps SWR for the rest of the time, which gives both instant deploys and edge-speed responses.

Choosing the Windows

The two numbers trade freshness against origin load and TTFB. A longer max-age means fewer revalidations but a longer period in which a deploy is invisible without a purge; a longer stale-while-revalidate window keeps more rarely visited pages instant, at the cost of those pages occasionally being older. The site measured several combinations for a week each before settling:

Edge hit ratio against worst-case staleness for four settings Four settings plotted by worst-case staleness without a purge and edge hit ratio. max-age 0: 0 minutes, 71 percent. max-age 60 with no SWR: 1 minute, 84 percent. max-age 60 with SWR 600: 11 minutes, 97 percent. max-age 300 with SWR 3600: 65 minutes, 98 percent. The chosen setting is the third, where hit ratio has nearly plateaued. Hit ratio gained vs staleness accepted 70% 100% 0 65 min worst-case staleness max-age 0 · 71% 60 s, no SWR · 84% 60 s + SWR 600 · 97% (chosen) 300 s + SWR 3600 · 98%
Past about ten minutes of stale window, extra staleness bought almost no extra hit ratio.

The curve flattens quickly because popular pages are requested often enough that someone always triggers the refresh within a minute; only the many rarely visited pages benefit from longer windows. Pick the knee of the curve, then add the purge on deploy so the worst case after a release is seconds rather than the window.

Fixed-Name Files Beyond HTML

A handful of non-HTML files keep the same URL while their content changes: sitemap.xml, RSS and Atom feeds, robots.txt, search index entry files such as pagefind-entry.json, and web app manifests. They belong in the same tier as HTML. Feeds in particular benefit: feed readers poll aggressively, often every few minutes from thousands of clients, and with a 60-second edge lifetime plus background revalidation almost all of those polls are answered from the edge. On this site, feed requests reaching origin fell from 31,000 a day to under 1,500 with no change in how quickly new posts appeared in readers' apps.

Step 3: Verify

curl -sI https://docs.example.com/guides/install/ | grep -iE 'cache-control|cdn-cache-control|age|cf-cache-status'
# cf-cache-status: HIT        age: 42
# (after 60 s) cf-cache-status: UPDATING or STALE, then HIT with a small age

Check from more than one location, because each edge holds its own copy: a VPN, a multi-region probe or a tool such as WebPageTest shows whether distant edges are also serving hits. Check a rarely visited page as well as the homepage, since popular pages mask problems by always being warm. And after a deploy, request a changed page immediately and again after a few seconds to confirm the purge (or the refresh) delivers the new version as expected. The age header shows how long the edge has held the copy; a status of STALE or UPDATING (naming varies by CDN) confirms background revalidation is happening.

Measured Impact

The 1,200-page docs site, two weeks before and after adding edge SWR for HTML with purge on deploy. RUM for TTFB; CDN analytics for origin requests.

Measuremax-age=0 everywhereEdge SWR + purge on deploy
HTML edge hit ratio71%97%
TTFB p75 (global RUM)142 ms51 ms
TTFB p75, readers in Asia-Pacific310 ms58 ms
Origin requests per day (HTML)96,0009,400
Time for a deploy to be visibleimmediate~3 s (purge)
TTFB by region before and after Paired bars of p75 time to first byte for HTML. Europe: 88 milliseconds before, 44 after. North America: 121 before, 49 after. Asia-Pacific: 310 before, 58 after. Global: 142 before, 51 after. HTML TTFB p75 (ms), before vs after Europe 88 → 44 N. America 121 → 49 Asia-Pacific 310 → 58 Global 142 → 51 web-vitals TTFB beacons, two weeks each; red before, green after
Readers furthest from origin gained most: their edge now answers instead of forwarding every request.

Pitfalls & Rollback

  • Long browser caching of HTML. Readers who refresh expect the latest page; keep browser max-age at or near zero and put longer windows on the edge only.
  • SWR without a staleness decision. Decide how old a page may be after a deploy, and either accept that window or purge.
  • Personalised or authenticated HTML. Never apply shared caching to responses that differ per user; use private.
  • Assuming your CDN honours the directive. Verify with age and cache-status headers.
  • Caching error pages. A transient 5xx or 404 cached with the same windows keeps a broken page up for minutes. Give error responses no-store or a much shorter lifetime.
  • Forgetting stale-if-error. Adding it lets the edge keep serving the last good page if the origin fails, which is a cheap resilience gain on self-hosted origins.
  • Rollback: restore max-age=0, must-revalidate for HTML; the effect is immediate for new requests and complete once the SWR window passes (or on purge).

Conclusion

For static HTML that changes a few times a day, revalidating every request is caution without benefit. An edge policy of max-age=60, stale-while-revalidate=600, browser revalidation kept at zero, and an HTML purge on each deploy raised the edge hit ratio on a 1,200-page docs site from 71% to 97%, cut global TTFB p75 from 142 to 51 ms (310 to 58 ms in Asia-Pacific), and still showed every deploy within seconds.

FAQ

What does stale-while-revalidate do?

It lets a cache serve a response that has just expired while it fetches a fresh copy in the background. The reader gets an instant response, and the next reader gets the updated version. It is set as a Cache-Control directive with a number of seconds.

Is it safe to cache HTML if content changes on deploy?

Yes, if the staleness is bounded and acceptable. With max-age=60 and stale-while-revalidate=600, a reader might see a page up to about eleven minutes old after a deploy. If that is too long, purge HTML on deploy or shorten the windows.

Do CDNs and browsers both honour it?

Modern browsers honour stale-while-revalidate in their HTTP cache. Many CDNs honour it too, some through a separate CDN-Cache-Control or Surrogate-Control header. Check your CDN's documentation and verify with response headers.

Does it affect hashed assets?

No. Hashed assets should keep a year-long immutable lifetime. stale-while-revalidate is for URLs that stay the same while their content changes, which on a static site means mainly HTML and a few fixed-name files.