Caching is one of the highest-leverage performance levers on the web, and almost entirely controlled by a small set of HTTP headers most developers have seen but never fully understood.
The core header: Cache-Control
Cache-Control: public, max-age=3600This tells any cache (the browser, a CDN, an intermediate proxy) that this response can be reused for up to 3600 seconds (one hour) without re-contacting the server at all. After that window, the cache is considered stale and needs to be revalidated or refetched.
The common directives, in practice
Cache-Control: no-store # never cache this, anywhere
Cache-Control: no-cache # cache it, but always revalidate first
Cache-Control: private, max-age=0 # user-specific, don't share across users
Cache-Control: public, max-age=31536000, immutable # cache "forever"no-cache is a common point of confusion — it doesn't mean "don't cache," it means "cache, but check with the server before using the cached copy every time." no-store is the directive that actually means "never cache this at all," appropriate for genuinely sensitive responses.
Immutable, fingerprinted assets: the "cache forever" pattern
The highest-value caching pattern on the modern web: assets whose filename changes whenever their content changes (a build hash appended to a JS bundle, for instance) can be cached essentially forever, because a content change always produces a new URL rather than invalidating the old one:
/_next/static/chunks/app-a1b2c3d4.js
Cache-Control: public, max-age=31536000, immutable
Since app-a1b2c3d4.js will never change (a code change produces app-e5f6g7h8.js instead), there's no staleness risk to caching it for a year — this is exactly the pattern behind fingerprinted static assets in every modern build tool, and it's the single biggest reason repeat visits to a well-built site feel instant.
ETags: cheap revalidation without re-downloading
An ETag is a hash or version identifier for a specific response body — it lets the browser ask "has this changed?" without re-downloading the full content if the answer is no:
# Initial response
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"
# Later, browser asks:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d"
# If unchanged, server responds:
HTTP/1.1 304 Not ModifiedA 304 response has no body at all — just a status code confirming the cached copy is still valid, which is far cheaper than re-sending the full response even for content too dynamic to set a long max-age on confidently.
CDNs: caching one layer earlier
A CDN caches responses at edge locations physically close to users, so most requests never reach your actual origin server at all. The same Cache-Control headers apply, but CDNs also support cache invalidation for the case content changes before max-age expires:
# CloudFront example — force an immediate cache refresh
aws cloudfront create-invalidation --distribution-id EXXXXXXXXXX --paths "/*"This is exactly the mechanism behind hosting a static site on S3 and CloudFront — long cache lifetimes on fingerprinted assets, short-or-none on entry points like index.html, with an explicit invalidation on deploy to guarantee fresh content reaches users immediately rather than waiting out the cache window.
Vary: caching different versions of the same URL
Sometimes a single URL should serve genuinely different content depending on a request header — a compressed vs. uncompressed response, or a different language. Vary tells caches to store a separate cached copy per distinct value of the named header, rather than one shared copy that might be wrong for a different request:
Vary: Accept-EncodingWithout Vary, a cache might serve a gzip-compressed response to a client that didn't ask for compression (or vice versa), because it only ever stored one version of the URL. Vary: Accept-Encoding tells it to key the cache by that header too, storing (and correctly serving) separate cached copies per distinct value.
Stale-while-revalidate: serving stale content while fetching fresh
A newer Cache-Control directive lets a cache serve a stale response immediately while fetching an updated one in the background, rather than making the user wait for revalidation on every stale hit:
Cache-Control: max-age=60, stale-while-revalidate=3600For the first 60 seconds, the response is fresh and served directly. For up to an hour after that, a stale copy is served immediately while a background request fetches the new version for next time — the user never waits on a slow revalidation round-trip, at the cost of occasionally seeing content up to an hour old.
Service workers: caching under application control
Beyond HTTP-header-driven caching, a service worker can intercept requests in the browser and implement entirely custom caching logic — useful for offline support or caching strategies HTTP headers alone can't express:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});This "cache falling back to network" pattern is the foundation of most offline-capable web apps — it's a genuinely different mechanism from Cache-Control (which is declarative and handled by the browser/CDN automatically), giving the application itself full programmatic control over what gets cached and when.
Common mistakes
- Setting a long
max-ageon a non-fingerprinted file (a plainapp.jswith no content hash in its name). If the content changes, users keep getting the stale cached version until the cache window expires — fingerprinted filenames are what make long caching safe in the first place. - Confusing
no-cachewithno-store.no-cachestill caches, just with mandatory revalidation;no-storegenuinely never caches — using the wrong one either over- or under-caches sensitive or frequently-changing content. - Caching a
private(user-specific) response withpublic, allowing a shared CDN or proxy to serve one user's personalized content to a different user. - Forgetting to invalidate a CDN's cache after a deploy and assuming users will see the new content immediately — without an explicit invalidation, a CDN keeps serving the previous cached version until its own cache window naturally expires.
Related reading
- Core Web Vitals Explained: What Actually Affects Your Score — shares tags: web-development, programming (same category).
- A Practical Guide to Responsive Design in 2026 — shares tags: web-development, programming.
- How to Host a Static Website on AWS S3 and CloudFront — shares tags: web-development.
- Next.js Rendering Strategies: SSG, SSR, ISR, and Static Export Compared — shares tags: web-development.
- OWASP Top 10, Explained for Developers Who Aren't Security Specialists — shares tags: web-development, programming.