stale-while-revalidate for HTML Entry Points That Reference Hashed Assets
Every deploy of a modern frontend ships two categories of files with opposite caching needs. Fingerprinted assets — JavaScript bundles, CSS files, images — carry content-addressed names like main.a3f9c21b.js and can be cached for a year with Cache-Control: max-age=31536000, immutable. The HTML entry point that references those filenames cannot. It is mutable: each deploy produces a new index.html whose only job is to point at the new hashed filenames. Cache it too long and users load stale JavaScript. Cache it too aggressively and every page load turns into an origin round-trip.
The stale-while-revalidate directive resolves this tension by letting a CDN serve a cached HTML response instantly while simultaneously fetching a fresh copy from the origin in the background. Because the old hashed asset URLs referenced by stale HTML are still valid — fingerprinted assets are immutable by design — serving the previous HTML for a few seconds or minutes after a deploy is genuinely safe. No broken references, no mixed-version panics, just slightly delayed delivery of the newest JavaScript until the revalidation completes.
Why HTML Is Different from Its Fingerprinted Assets
The asymmetry comes from what changes and when. A content hash in a filename encodes the exact bytes of that file. If those bytes never change, neither does the hash, and a cached copy is indistinguishable from an origin copy — the CDN is not lying to the browser. The HTML entry point contains no stable identity. After a deploy, index.html at the same URL holds entirely different content. A cache that holds the previous version is, strictly speaking, serving outdated data.
What makes SWR safe for HTML is the guarantee provided by asset fingerprinting: the old hashed filenames are still present on the CDN. The previous main.a3f9c21b.js was not deleted; the new deploy added main.b7e14d09.js alongside it. A user who receives stale HTML pointing at main.a3f9c21b.js gets a working application — the assets that HTML references are still valid CDN entries. They will only receive the new HTML, and therefore the new JavaScript, once the background revalidation completes and the CDN cache is updated.
This is the core insight: SWR for HTML is safe precisely because asset cache key architecture keeps old and new bundles alive in parallel. If your deploy pipeline immediately purges previous asset versions, SWR on HTML becomes dangerous. Keep old hashed assets alive for at least as long as the SWR window plus a safety margin.
How stale-while-revalidate Works
The directive is specified in the Cache-Control response header alongside max-age:
Cache-Control: max-age=60, stale-while-revalidate=3600
This instructs any compliant cache — browser, CDN, or intermediary — using the following logic:
- For the first 60 seconds after caching, serve the response directly from cache without revalidation (the fresh window).
- For the next 3600 seconds (the stale window), serve the cached response immediately without waiting for the origin, but simultaneously send a background request to the origin to update the cache.
- After 60 + 3600 = 3660 seconds with no revalidation success, the response is stale-if-error or must be revalidated synchronously before serving.
From the user’s perspective, requests during the stale window feel instant — there is no origin round-trip in the critical path. The background fetch updates the cache entry for subsequent requests. A user who loads the page one minute after a deploy may get stale HTML; a user who loads it five minutes after the deploy gets fresh HTML, because the background revalidation has already completed and updated the CDN cache.
This behavior differs from no-cache, which forces a conditional GET to the origin on every request, and from a short max-age=60 with no SWR directive, which serves from cache for 60 seconds then blocks the next request while synchronously fetching from the origin.
Understanding the Age header is essential for confirming SWR behavior. The Age response header reports how many seconds have elapsed since the response was stored in a cache. When Age is between 0 and max-age, the response is fresh. When Age is between max-age and max-age + stale-while-revalidate, the response is stale but validly served while revalidation occurs. When Age exceeds the sum, the response has left the SWR window entirely.
Comparison: no-cache vs. stale-while-revalidate vs. Short max-age
| Strategy | Header value | User experience | Deploy safety | CDN revalidation behavior | Risk |
|---|---|---|---|---|---|
no-cache |
Cache-Control: no-cache |
Every load hits origin; high latency under load | Immediate — every user gets new HTML on next request | CDN sends conditional GET (If-None-Match / If-Modified-Since) on every request | Origin overload during traffic spikes; no CDN offload for HTML |
Short max-age only |
Cache-Control: max-age=60 |
Fast for first 60 s; one user per interval gets slow synchronous revalidation | Up to 60 s lag before new HTML serves | CDN revalidates synchronously at TTL expiry; request waits | The “thundering herd” expiry problem: spike of synchronous revalidations at TTL boundary |
stale-while-revalidate |
Cache-Control: max-age=60, stale-while-revalidate=3600 |
Always fast; background fetch is invisible to users | New HTML appears within seconds to minutes of deploy | CDN revalidates asynchronously; no request is blocked | Slightly stale HTML served during revalidation window — safe if old assets remain valid |
immutable (assets only) |
Cache-Control: max-age=31536000, immutable |
Maximally fast; no revalidation ever | Only safe for content-addressed filenames | CDN never revalidates; serves forever from cache | Wrong for HTML; catastrophic if applied to mutable entry points |
The stale-while-revalidate row wins on user experience and origin load simultaneously. The tradeoff is accepting that a window of requests — between the max-age expiry and the background revalidation completing — may receive HTML pointing at the previous asset set. Sizing this window requires balancing two risks: too short a stale-while-revalidate value and background revalidation may not complete before the window closes; too long and users who experience cold CDN pops may see very old HTML.
A practical default for most applications is max-age=60, stale-while-revalidate=86400. The 60-second fresh window means most CDN pops stay fresh between routine traffic. The 24-hour stale window ensures that low-traffic CDN pops — which may see requests only once per day — still serve fast responses rather than blocking on an origin round-trip.
Request Flow Diagram
Provider Support and Behavior Differences
The stale-while-revalidate directive is part of RFC 5861 and was incorporated into the HTTP Caching specification (RFC 9111). Browser support is universal across modern browsers. CDN support varies.
Cloudflare honors stale-while-revalidate at the edge. When a cached response enters the stale window, Cloudflare serves it to the requesting user and sends a background revalidation request to the origin. The revalidation uses the same cache key as the original request. You can observe CDN-level SWR behavior through the CF-Cache-Status: REVALIDATED header that Cloudflare sets after a background fetch completes. Configuring Cloudflare Cache Rules to apply a custom Cache-Control override for text/html responses is the recommended path rather than relying on origin headers alone — this keeps caching logic centralised at the CDN layer.
Nginx does not honor stale-while-revalidate natively as a proxy cache directive. The proxy_cache_use_stale updating directive provides analogous semantics: when the cache entry is expired and a revalidation is in progress, subsequent requests are served the stale entry rather than queuing. For Nginx cache purge workflows in combination with SWR, proxy_cache_lock and proxy_cache_lock_timeout prevent thundering-herd revalidation spikes.
Amazon CloudFront introduced native support for stale-while-revalidate and stale-if-error in 2023. You must configure a Cache Policy (not a Legacy Cache Setting) with “Cache compressed objects” enabled and leave the Cache-Control header uncollapsed in the origin response. CloudFront respects the directive at the distribution level. The AWS CloudFront invalidation workflow interacts with SWR: an explicit invalidation for index.html immediately evicts the cache entry, bypassing any remaining stale window.
Runnable Configuration Examples
Nginx: stale-while-revalidate via proxy_cache_use_stale
# /etc/nginx/conf.d/app.conf
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=html_cache:10m
max_size=100m inactive=1d use_temp_path=off;
server {
listen 443 ssl http2;
server_name example.com;
location = /index.html {
proxy_pass http://origin_upstream;
proxy_cache html_cache;
proxy_cache_key "$scheme$host$request_uri";
# Fresh for 60s; background revalidation for next 3600s
proxy_cache_valid 200 60s;
proxy_cache_use_stale updating error timeout;
# Allow only one request to hit origin during revalidation
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Pass stale-while-revalidate to the browser too
add_header Cache-Control "max-age=60, stale-while-revalidate=3600";
# Expose cache status for debugging
add_header X-Cache-Status $upstream_cache_status;
}
location ~* \.(js|css|png|woff2|svg)$ {
proxy_pass http://origin_upstream;
# Fingerprinted assets: immutable for one year
add_header Cache-Control "public, max-age=31536000, immutable";
proxy_cache_valid 200 365d;
}
}
The updating keyword in proxy_cache_use_stale is the closest Nginx equivalent to stale-while-revalidate: while one request revalidates the cache entry, all other concurrent requests receive the stale entry instead of queuing.
Cloudflare Workers: setting Cache-Control on HTML responses
// workers/cache-headers.js
// Deploy via wrangler: wrangler deploy workers/cache-headers.js
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Proxy to origin
const response = await fetch(request);
// Clone so we can modify headers
const headers = new Headers(response.headers);
const isHtml =
url.pathname === "/" ||
url.pathname.endsWith("/index.html") ||
headers.get("Content-Type")?.includes("text/html");
if (isHtml) {
// 60s fresh + 24h stale-while-revalidate for HTML entry points
headers.set(
"Cache-Control",
"public, max-age=60, stale-while-revalidate=86400"
);
} else if (/\.(js|css|woff2|png|svg|avif|webp)(\?.*)?$/.test(url.pathname)) {
// Fingerprinted assets: immutable
headers.set(
"Cache-Control",
"public, max-age=31536000, immutable"
);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
Deploy with wrangler deploy and route all traffic through the Worker. Cloudflare will respect the stale-while-revalidate directive set by the Worker and perform background revalidation at the edge.
CloudFront: Cache Policy via CloudFormation
# cloudformation/cache-policies.yaml
AWSTemplateFormatVersion: "2010-09-09"
Resources:
HtmlCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: html-swr-policy
DefaultTTL: 60 # seconds — matches max-age=60
MinTTL: 0
MaxTTL: 86400 # ceiling for stale-while-revalidate window
ParametersInCacheKeyAndForwardedToOrigin:
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig:
HeaderBehavior: none
CookiesConfig:
CookieBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
DefaultCacheBehavior:
CachePolicyId: !Ref HtmlCachePolicy
ViewerProtocolPolicy: redirect-to-https
TargetOriginId: app-origin
# CloudFront will forward Cache-Control from origin, including SWR
ResponseHeadersPolicyId: !Ref SecurityHeadersPolicy
Ensure the origin response includes Cache-Control: public, max-age=60, stale-while-revalidate=86400. CloudFront forwards the directive to the browser and also acts on it at the edge.
Verification: Confirming SWR Is Working
The most reliable verification method uses two timed requests and the Age header.
# First request: prime or enter the stale window
curl -si https://example.com/ | grep -E "^(Cache-Control|Age|CF-Cache-Status|X-Cache):"
# Wait a moment, then make a second request
sleep 5
curl -si https://example.com/ | grep -E "^(Cache-Control|Age|CF-Cache-Status|X-Cache):"
On Cloudflare, look for:
CF-Cache-Status: HITwith anAgevalue greater than themax-age— this confirms the response is being served from the stale window.- After the background fetch completes,
CF-Cache-Status: REVALIDATEDwill appear for the response that triggered the background fetch (visible in Cloudflare Logpush, not in the response to the end user). Ageresets to a low value once the background fetch has updated the cache.
In browser DevTools, open the Network panel, filter for document requests, and reload the page twice with the cache preserved (not hard-reload). On the second load, the Age header should be incrementing — the response came from cache. If Age exceeds your max-age and the page still loaded fast, SWR is serving stale HTML while the background fetch runs.
For Nginx, check the X-Cache-Status header set by the configuration above. A value of STALE confirms the stale path was taken; UPDATING means a background revalidation is in progress.
When to Reconsider stale-while-revalidate
SWR on HTML is the wrong choice in several scenarios:
Authenticated HTML that contains session-specific content. If index.html renders user-specific data at the CDN level (rather than fetching it via API), serving a previous user’s HTML to a new user is a privacy violation. Use Cache-Control: private, no-cache and never cache at the CDN for personalised HTML.
Deploys that remove asset versions immediately. If your CI pipeline deletes the previous hashed bundles from the origin or CDN on every deploy, the stale HTML will reference filenames that no longer exist. Either keep old asset versions alive for the SWR window duration, or abandon SWR and use no-cache on HTML until your pipeline supports asset retention.
Very short deploy cycles. If you deploy multiple times per hour and the SWR window is 24 hours, users may load HTML that is several deploys behind. This is rarely harmful — they get the old JavaScript, which still works against the old API — but if a deploy includes a breaking API change that must be coordinated, a short max-age=0, stale-while-revalidate=30 is safer than a long window.
Environments where CDN SWR support is absent. Not all CDNs honor stale-while-revalidate. Verify your CDN’s behavior with the Age header check above. If the CDN ignores the directive, all stale-window logic falls through to the browser — which does honor SWR — but CDN-level stale serving (the performance benefit) is lost.
The ETag-vs-immutable comparison covers the complementary case: when to use conditional GET with ETags for assets instead of immutable fingerprinting, which changes the deploy safety calculus for SWR on HTML. Understanding how fingerprinting appears in HTTP headers helps clarify which layer of caching each directive controls.
Related
- Cache-Control Immutable and TTL Tuning — parent section covering the full TTL strategy for fingerprinted assets
- ETag vs. immutable Cache-Control for assets — how conditional validation with ETags interacts with the immutable flag on fingerprinted files
- Cloudflare Cache Rules and Purge — configuring CDN-level cache rules that complement SWR header logic
- AWS CloudFront Invalidation — explicit invalidation workflows and how they override the stale-while-revalidate window