The Goal: Eliminate the Request Entirely
Most performance advice focuses on making requests faster — smaller payloads, CDN placement, HTTP/2 multiplexing. Caching is different. Its goal is to make the request not happen at all. A cache hit from the browser's local disk is effectively zero latency. That's one of the highest-value things you can optimize.
HTTP caching has two layers: the browser's local cache, and any intermediaries between the browser and your origin server (CDNs, reverse proxies). The same Cache-Control headers govern both, with some directives specific to one layer or the other.
Cache-Control Directives
Cache-Control is a response header your server sends. It's a comma-separated list of directives:
Cache-Control: public, max-age=31536000, immutable
max-age=N tells every cache (browser + CDN) to consider the response fresh for N seconds. After that, the cache must revalidate before serving it again. max-age=31536000 is one year — the standard value for versioned static assets.
no-cache is widely misunderstood. It does not mean "don't cache." It means "cache this, but always revalidate with the server before serving it." The browser stores the response, but checks in every time. If the server says nothing changed, the browser serves from cache without re-downloading the body. Useful for HTML.
no-store is the true "never cache" directive. The response is not stored anywhere, period. Use this for sensitive data — banking pages, auth tokens, personal dashboards.
private means only the browser can cache it; CDNs and shared proxies must not. For logged-in content — a page that shows a user's account — you want private so the CDN doesn't serve user A's data to user B.
public explicitly marks the response as cacheable by shared caches (CDNs). Responses to requests with Authorization headers default to private; you need public to override that.
immutable is a hint to the browser that the resource will never change for its max-age lifetime. This prevents conditional revalidation requests on page reload. It only makes sense paired with a long max-age and content-hashed filenames.
Cache Validation: ETags and Last-Modified
What happens when a cached resource expires? The browser doesn't throw it away and re-download — it validates with the server first, asking: "has this changed since I last fetched it?"
There are two mechanisms for this:
ETag + If-None-Match. The server sends an ETag header with the response — a unique identifier for this version of the content, typically a hash:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
When the cache expires, the browser sends the ETag back in an If-None-Match request header. The server compares it to the current resource version. If nothing has changed, it responds with 304 Not Modified — no body, just headers. The browser uses its cached copy. If the resource changed, the server sends the new version with a new ETag.
Last-Modified + If-Modified-Since. Older but still widely used. The server sends when the resource was last changed:
Last-Modified: Wed, 07 May 2026 12:00:00 GMT
On revalidation, the browser sends If-Modified-Since: Wed, 07 May 2026 12:00:00 GMT. Same logic: 304 if unchanged, full response if changed.
ETags are more reliable because timestamps have one-second granularity — fast-changing resources can be missed by Last-Modified. Most servers support both and prefer ETags when present.
Cache Busting with Content Hashes
The classic caching dilemma: you want your static assets (JS, CSS, fonts) cached forever for returning visitors, but you need changes to take effect immediately after a deploy.
The solution is content-addressed filenames. When your build tool generates a file, it includes a hash of the file's content in the filename:
main.a3f9b2c1.css
app.7e4d1892.js
Now you can set max-age=31536000, immutable on these files safely. The URL itself changes when the content changes — the old URL stays cached (correctly), and the new URL is fetched fresh. Your HTML references the new hashed URL after each build.
Cache-Control: public, max-age=31536000, immutable
For the HTML file itself, you do the opposite: short max-age or no-cache, so browsers always get the latest version that points to the new hashed assets:
Cache-Control: no-cache
This two-tier strategy — long cache on assets, short or no cache on HTML — is how fast, reliably-updating sites work.
Service Workers and the Cache API
Service workers add a programmable cache layer that runs in a background thread. Unlike HTTP caching (controlled by server response headers), the Cache API lets your JavaScript decide what to store and when to serve it:
// In your service worker
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
caches.open('v1').then(cache => cache.put(event.request, response.clone()));
return response;
});
})
);
});
This is the foundation of offline-capable apps. The service worker intercepts all requests and can serve from its own cache even without a network connection. It's also useful for precise cache invalidation — you can version your cache stores and delete old ones programmatically.
Service worker caching layers on top of HTTP caching, not instead of it. A request that hits the service worker cache never reaches the network. One that misses may still hit the HTTP cache before reaching your origin.
Common Mistakes
Caching HTML with a long max-age is the most damaging mistake. If your HTML is cached for a week and you push a fix, users see the broken version for up to a week. Keep HTML on no-cache or max-age=0.
Setting a short max-age on hashed asset bundles throws away the benefit. If main.a3f9b2c1.js has max-age=3600, every user re-downloads it hourly. Since the hash guarantees freshness, there's no reason not to set a year-long cache.
CDN caching vs browser caching: A CDN in front of your origin caches responses at the edge. This is separate from what's in the user's browser. You can serve Cache-Control: public, max-age=3600, s-maxage=86400 — max-age controls browser TTL, s-maxage controls shared caches like CDNs. This lets you keep CDN copies fresh for a day while still allowing browsers to revalidate hourly.
For understanding what's actually in a URL before caching decisions, the URL Encoder helps decode and inspect query strings. The Hash Generator is useful for manually verifying content hashes if you're debugging a cache busting setup. For a broader look at the headers that go back and forth with every cached or revalidated request, Understanding HTTP Headers covers the full header vocabulary. And How Browsers Render a Page explains how the cache fits into the overall resource loading sequence during page load.