Web DevelopmentIntermediate

HTTP Caching Explained: Cache-Control, ETags, and CDNs

Where a cached response actually lives, how the browser and a CDN decide whether to trust it, and the specific headers that control all of it.

DevFieldGuideJuly 21, 2026 (updated July 23, 2026)6 min read
Share:

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

http
Cache-Control: public, max-age=3600

This 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.

RequestBrowser checks its cache first
Fresh (within max-age)Served instantly, no network request
StaleRevalidation request sent (ETag/Last-Modified)
304 or 200304: keep cached copy. 200: fetch new content

The common directives, in practice

http
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:

http
# Initial response
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"
 
# Later, browser asks:
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d"
 
# If unchanged, server responds:
HTTP/1.1 304 Not Modified

A 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:

bash
# 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:

http
Vary: Accept-Encoding

Without 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:

http
Cache-Control: max-age=60, stale-while-revalidate=3600

For 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:

js
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

Common mistakes
  • Setting a long max-age on a non-fingerprinted file (a plain app.js with 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-cache with no-store. no-cache still caches, just with mandatory revalidation; no-store genuinely never caches — using the wrong one either over- or under-caches sensitive or frequently-changing content.
  • Caching a private (user-specific) response with public, 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.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Web Development

View all