Browser caching can make your site feel lightning fast, but it’s a surprisingly intricate dance between your server and the user’s browser.

Let’s see it in action. Imagine you’re fetching a CSS file. Your browser asks your server for style.css.

GET /style.css HTTP/1.1
Host: example.com

Your server might respond like this:

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=31536000
Expires: Tue, 15 Mar 2025 10:00:00 GMT
ETag: "abcdef12345"
Last-Modified: Mon, 15 Mar 2024 10:00:00 GMT

This tells the browser: "You can keep this style.css for a year (max-age=31536000), specifically until March 15, 2025 (Expires). And hey, if you want to check if it’s changed, look for the ETag value 'abcdef12345' or check the Last-Modified date."

The primary problem browser caching solves is reducing latency and server load. Instead of re-downloading every single asset for every page view, the browser can serve them directly from its local storage. This dramatically speeds up page load times, especially for repeat visitors, and significantly cuts down on the number of requests your server has to handle. It’s like having a local library for your website’s building blocks.

Internally, the browser maintains a cache of resources it has previously downloaded. When you request a resource, the browser first checks its cache. If a valid, unexpired copy exists, it uses that instead of making a network request. If not, or if the cached copy might be stale, it makes a conditional request to the server.

The key levers you control are HTTP headers. Cache-Control is the modern, powerful directive. max-age sets the duration in seconds for which the response is considered fresh. public means it can be cached by any cache (browser, proxy), while private restricts it to the user’s browser. no-cache still performs validation but doesn’t serve stale content. no-store completely prevents caching. Expires is an older, less flexible header that specifies an absolute expiration date. While still supported, Cache-Control takes precedence. ETag and Last-Modified are for validation. When a cached resource’s max-age expires, the browser can send a conditional request using If-None-Match (with the ETag value) or If-Modified-Since (with the Last-Modified date). If the resource hasn’t changed, the server responds with 304 Not Modified, saving bandwidth.

The most surprising thing about Cache-Control: no-cache is that it doesn’t actually prevent caching. It merely instructs the browser to revalidate the resource with the origin server before using its cached copy. This means a no-cache resource can still be served quickly if the server responds with a 304 Not Modified, saving bandwidth and reducing load compared to a full re-download. It’s a common point of confusion, leading developers to believe it completely disables caching when its purpose is more nuanced validation.

When you update a file, say style.css, and deploy it, your users might still see the old version because their browser is happily serving the cached copy. This is where cache busting comes in. The most robust method is to change the filename itself. Instead of style.css, you might deploy style.v1.css or, more commonly, append a hash of the file’s content: style.a1b2c3d4.css. When the file content changes, the hash changes, and the browser sees a new filename, forcing it to download the updated version. You’ll need a build process (like Webpack, Parcel, or Gulp) to automate this renaming and update your HTML references accordingly.

The next concept you’ll encounter is how different caching directives interact, especially when Cache-Control and Pragma headers are present, and how proxy caches behave differently from browser caches.

Want structured learning?

Take the full Caching-strategies course →