Cache-Control, ETags and stale-while-revalidate for Static Exports
How to configure Cache-Control, ETags, and stale-while-revalidate for statically exported sites when you have no server to negotiate cache.

You've deployed a static export of your Next.js site to S3 behind CloudFront. The build finishes, the deploy pipeline runs, and users see the new homepage—except some don't. They see last week's version because their browser cached the HTML with max-age=31536000. You invalidate the CloudFront distribution, wait five minutes, and still, users with a warm cache see stale content. The problem isn't your code; it's that a static export has no server to negotiate cache freshness.
What Cache Mechanics Are Missing From a Static Export
Static exports produce immutable files. Every page is a pre-rendered HTML document, every script a hashed chunk. There is no running server to inspect If-None-Match headers, return 304 responses, or compute freshness on the fly. Your origin is an object store—S3, Cloudflare R2, or a plain web server serving flat files. These origins can set Cache-Control headers on upload, but they cannot negotiate.
The default behaviour for most static hosting services is to set a long cache lifetime on everything. Cloudflare Pages applies Cache-Control: public, max-age=31536000, immutable to all assets by default. S3 buckets with static hosting have no automatic caching headers at all, but the common recommendation is to set max-age=31536000 on versioned files and something shorter on HTML. The problem is that "something shorter" is often still too long for content that changes between deploys, and there is no mechanism to say "serve this stale copy while you check for a new one in the background."
The real-world impact is measurable. A marketing site that publishes a new blog post every morning will show the old post to anyone who visited yesterday and still has the HTML cached. Query-string busting (?v=2) works for JavaScript and CSS, but appending a cache-busting parameter to a URL like /blog/post-1 changes the URL itself, which means search engines treat it as a different page. That breaks SEO for listing pages and canonical links.
Configuring Cache-Control for Each Asset Type
You need different cache policies for different file types. For a Next.js static export, the _next/static directory contains versioned files with content hashes in their filenames—those can be cached indefinitely. The HTML files in the root and under nested paths should have a short TTL with a stale-while-revalidate window.
Here's a concrete next.config.js snippet that sets custom headers for a static export:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
async headers() {
return [
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=600, stale-while-revalidate=3600',
},
],
},
];
},
};
module.exports = nextConfig;The stale-while-revalidate=3600 directive tells the cache that it may serve a stale response for up to one hour while it revalidates asynchronously. The first request after max-age expires triggers a revalidation; subsequent requests within the window get the stale copy. This pattern is documented in the HTTP Cache-Control extension RFC 5861.
If you're using Astro or Eleventy, you don't get a headers() config in the build tool. You'll need to set these headers at the CDN level. For CloudFront, a CloudFront Function or Lambda@Edge can inspect the request URI and add the appropriate Cache-Control header to the origin response. The function runs on every viewer request, but the overhead is negligible (sub-millisecond). The key is mapping path patterns: /assets/* gets the immutable policy, everything else gets the short TTL with stale-while-revalidate.
Generating and Embedding ETags at Build Time
Without a server, you cannot generate ETags dynamically. But you can compute them at build time and embed them in the response headers or in the HTML itself. The approach depends on whether you control the CDN configuration or only the static files.
The build-time approach is straightforward. After your static export completes, iterate over every output file, compute a SHA-1 hash of its contents, and store the mapping in a JSON manifest. Here's a script that does this for a Next.js static export:
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const outDir = './out';
const etagManifest = {};
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else {
const content = fs.readFileSync(fullPath);
const hash = crypto.createHash('sha1').update(content).digest('hex');
const relativePath = path.relative(outDir, fullPath);
etagManifest['/' + relativePath.replace(/\\/g, '/')] = `"${hash}"`;
}
}
}
walk(outDir);
fs.writeFileSync('./etags.json', JSON.stringify(etagManifest, null, 2));You then have two integration strategies:
CDN-side lookup: Upload the etags.json manifest alongside your static files. A CloudFront Function reads the manifest on origin response, looks up the request path, and sets the ETag header. The CDN then handles conditional requests natively. This preserves browser-native caching—the browser sends If-None-Match, and the CDN returns 304 if the ETag matches.
Client-side revalidation: Embed the ETag as a <meta http-equiv="ETag"> tag in the HTML. On page load, a small JavaScript snippet reads the meta tag, stores it in localStorage, and on subsequent visits compares the stored value against the meta tag. If they differ, it fetches the page via fetch() and swaps the content. This avoids CDN configuration but adds JavaScript overhead and delays the first paint. I've used this approach on a project where the CDN was managed by a separate team and we couldn't change origin response headers. It works, but the user sees a flash of stale content before the client-side swap completes.
stale-while-revalidate in Practice: CDN vs Browser
stale-while-revalidate operates at two layers: the browser's HTTP cache and the CDN's edge cache. The behaviour differs between them.
At the browser layer, the Cache-Control: public, max-age=600, stale-while-revalidate=3600 header tells the browser it may serve a stale response for up to an hour after the 10-minute freshness window. The browser initiates a background fetch to update the cache. This works in Chrome and Firefox; Safari's implementation has historically been inconsistent.
At the CDN layer, you configure a cache policy that overrides the origin headers. For CloudFront, you create a Cache Policy with a 0-second minimum TTL and a 24-hour stale-while-revalidate window. This forces the edge to always serve stale content while revalidating against the origin:
| Layer | Cache-Control Header | Behaviour |
|---|---|---|
| Browser | max-age=600, stale-while-revalidate=3600 |
Serves fresh for 10 min, then stale for 60 min while revalidating in background |
| CDN Policy | 0s TTL, 24h stale-while-revalidate | Always serves stale from edge, revalidates on every request |
| Origin (S3) | No cache headers | Returns full content on every request |
The gotcha: browsers treat stale-while-revalidate as a hint, not a guarantee. Chrome will still fetch fresh content if it detects a stale response via other heuristics—for example, if the Date header is more than 10% beyond the max-age. Some corporate proxies and mobile carriers strip or ignore the directive entirely. You cannot rely on it for correctness; use it only for performance.
Failure Modes: Stale HTML and Hydration Mismatches
The most common failure mode in static exports with aggressive caching is a hydration mismatch. The browser loads stale HTML from cache, then fetches the latest JavaScript bundles separately. React compares the server-rendered DOM against what the client components expect, finds differences, and throws warnings or re-renders the entire tree, causing a visible flash.
The fix is versioning your client bundles. Next.js does this automatically with [contenthash] in chunk filenames. The stale HTML references old chunk filenames, and when the browser fetches those specific URLs, it gets the old versions from its own cache. The new JavaScript never loads against old HTML because the filenames don't match. This is the same principle behind the immutable directive on _next/static.
A trickier edge case occurs with shared caches like corporate proxies or ISP-level transparent proxies. These caches serve the same stale content to multiple users. If one user triggers a revalidation that updates the cache, subsequent users see the new version. But if the revalidation fails (origin is down, network timeout), the proxy may continue serving the stale version for the entire stale-while-revalidate window. Multiple users can see wildly different versions of the same page depending on when their requests hit the proxy.
When to Skip These Techniques Altogether
If your static site regenerates fully on every deploy and you deploy infrequently, the simplest approach is a long immutable cache on everything with a low TTL on HTML. Set max-age=300 on HTML files and max-age=31536000 on assets. No stale-while-revalidate, no ETag manifest, no edge functions. The complexity of these techniques adds zero value for a site with fewer than 100 pages that updates less than once per day.
For sites that need per-user caching granularity or millisecond cache invalidation, static exports are the wrong tool. Incremental Static Regeneration (ISR) in Next.js gives you on-demand revalidation at the server level. Server-side rendering gives you full control over cache headers per request. Both require a running Node.js server. If your use case demands that user A sees different content than user B on the same URL, or that a content update propagates in under a second, don't fight the static export model—switch to a server-rendered approach.
Testing Your Cache Behaviour
Verifying cache headers requires two checks: that the CDN respects stale-while-revalidate and that the browser honours the ETag on subsequent visits.
For the CDN check, use curl with verbose output:
curl -I https://yoursite.com/blog/post-1Look for Cache-Control and Age headers. The Age header tells you how long the response has been cached at the edge. Make a second request immediately and check that Age increases. Then wait past the max-age window and request again—you should see Age reset to a low value, indicating the CDN revalidated.
For the browser check, open DevTools, go to the Network tab, and ensure "Disable cache" is unchecked. Load a page, then reload. You should see the first request return a 200 with a full response, and the second request return a 304 (if ETags are configured) or a 200 with from disk cache (if the browser cache is still fresh). After the max-age expires, the browser should send a conditional request with If-None-Match or If-Modified-Since.
Automate this with a Puppeteer script that caches a page, updates a file, rebuilds, and asserts the CDN serves the new version within the expected revalidation window. This catches regressions when you change CDN configuration or deploy infrastructure updates.
If you're dealing with hydration mismatches from stale HTML, the View Transitions API for Multi-Page Apps Without a Framework offers an alternative approach to smooth over visual inconsistencies during page transitions. And if you're already in the React ecosystem, Fixing INP in React Apps That Already Pass LCP covers performance patterns that become critical when cache strategies introduce latency.
Key takeaways
- Static exports lack a server to negotiate cache freshness, so you must set
Cache-Controlheaders at build time or via CDN configuration. - Use
stale-while-revalidateto serve stale content while revalidating in the background—it works at both the browser and CDN layers, but browsers treat it as a hint. - Compute ETags at build time and embed them in a manifest for CDN-side lookup, or in
<meta>tags for client-side revalidation; each approach has trade-offs in complexity and performance. - Versioned chunk filenames (Next.js content hashes) prevent hydration mismatches when stale HTML loads alongside new JavaScript bundles.
- Skip these techniques for low-traffic, infrequently updated sites—a simple short TTL on HTML and long TTL on assets is more reliable.
Frequently asked questions
- What is the right Cache-Control value for a static export?
- Set a short max-age and a longer stale-while-revalidate. For example, Cache-Control: public, max-age=60, stale-while-revalidate=86400 means browsers serve cached content for 60 seconds, then use stale content while revalidating up to 24 hours later.
- Can I use ETags without a server?
- Yes, but you must generate them at build time and embed them in static files (like a JSON manifest or meta tags). Then client-side JavaScript compares the current ETag against the stored one and triggers a fetch if they differ.
- How do I make these work behind a CDN?
- Lambda@Edge or CloudFront Functions can inspect the request, map to your static file, compute or compare an ETag from a build-time manifest, and return 304 when the file hasn't changed. This gives you server-grade cache behavior on a static origin.


