HTTP Caching for Static Sites

notes

This note covers the HTTP caching headers that matter for static sites, how CDN edge caching interacts with browser caching, and the specific configuration that works well on Cloudflare Pages. If you have shipped a static site and wondered why your CSS changes are not showing up, or why Lighthouse keeps yelling about caching, this is the practical answer.

The Two Caches You Care About

When someone requests a page from your static site, two caches sit between your origin and their browser:

  1. CDN edge cache — Cloudflare (or whatever CDN you use) caches your content at edge nodes around the world. This cache serves most requests without touching your origin at all.

  2. Browser cache — The visitor’s browser stores responses locally. If the browser has a cached copy and the cache headers say it is still fresh, no network request happens at all.

These two caches have different needs. The CDN cache should be aggressive — cache everything, invalidate on deploy. The browser cache should be cautious — you do not want visitors stuck with stale HTML after you push a fix.

Cache-Control for Static Assets

For assets with content hashes in their filenames (e.g., main.a3f2c1.css), use the longest cache lifetime possible:

Cache-Control: public, max-age=31536000, immutable

The immutable directive tells the browser not to bother revalidating this resource — ever. Since the filename changes when the content changes, this is safe. The browser uses the cached version until you deploy new content with a new hash, which gets a new URL.

This is the single most impactful caching optimization for static sites. Hashed assets served with immutable result in zero network requests on repeat visits.

Cache-Control for HTML Pages

HTML pages are different. You want them cached for performance but revalidated frequently so visitors see fresh content after a deploy:

Cache-Control: public, max-age=0, must-revalidate

Or, if you want a short grace period:

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

The second approach lets the CDN serve slightly stale content while it fetches a fresh copy in the background. For a blog or documentation site, a five-minute staleness window is usually acceptable.

ETags and Conditional Requests

When max-age expires, the browser sends a conditional request with the If-None-Match header containing the previous response’s ETag. If the content has not changed, the server responds 304 Not Modified — no body, just headers. This is fast and cheap.

Cloudflare generates ETags automatically for static content. You do not need to configure this manually. The flow looks like:

  1. First visit: browser gets the full response + ETag: "abc123"
  2. Repeat visit after max-age expires: browser sends If-None-Match: "abc123"
  3. If unchanged: server responds 304 (no body transferred)
  4. If changed: server responds 200 with the new content + new ETag

Cloudflare Pages Specifics

On Cloudflare Pages, you configure caching through a _headers file in your build output. Here is a practical configuration:

/*
  Cache-Control: public, max-age=0, must-revalidate

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

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

The first rule applies to all paths (HTML pages). The subsequent rules override it for asset directories. Cloudflare’s edge cache has its own TTL logic that supplements these browser-facing headers.

You can read more about how Cloudflare handles caching for Pages deployments in the Cloudflare Pages documentation on headers.

Common Mistakes

Forgetting to hash asset filenames. If your CSS file is always called style.css, you cannot set a long max-age without risking visitors seeing stale styles. Use your build tool’s content hashing feature.

Setting long max-age on HTML. Visitors get stuck on old pages. Even if your CDN purges its cache on deploy, the browser cache does not know about it.

Ignoring Vary headers. If your server returns different content based on Accept-Encoding (e.g., gzip vs brotli), the Vary: Accept-Encoding header ensures the browser caches each variant separately. Cloudflare handles this automatically, but custom servers sometimes get it wrong.

Over-caching API responses. If your static site fetches data from an API at build time and embeds it in HTML, the caching strategy applies to the HTML, not the API. This is usually fine, but worth remembering if you have near-real-time data expectations.

The Bottom Line

For a typical static site: long-lived immutable caching on hashed assets, short-lived or revalidation-based caching on HTML, and let the CDN handle edge distribution. This setup gives you fast repeat visits, instant cache busting on deploys, and minimal operational complexity. You don’t need a caching framework. You need three lines in a _headers file.