32.6 Cache-Control Headers for Static Assets
Right, let’s talk about caching. Or more specifically, let’s talk about stopping your users from wanting to throw their laptops out a window because your website’s logo took three minutes to load on a fresh visit. It’s 2024. We’re better than that. The magic wand for this particular problem is the Cache-Control header, and for static assets—your CSS, JavaScript, images, fonts—it’s non-negotiable. This isn’t a polite suggestion; it’s the foundation of a performant site.
Think of it this way: every time a browser needs a file, it has to make a trip all the way to your server. That’s a long journey, full of network latency, packet loss, and general internet nonsense. Caching is you saying, “Hey browser, here’s this file. I want you to keep a copy of it right there on your hard drive for a while so you don’t have to bug me for it again tomorrow.” The Cache-Control header is you writing the very specific rules for that agreement.
The Golden Rule: max-age and immutable
For versioned static assets—you know, the ones you’ve painstakingly named like main.a1b2c3d4.css—you can be brutally aggressive. These filenames change when the content changes, so it’s perfectly safe to tell the browser to hang onto them forever. Well, almost forever. A year is the conventional “forever” of the internet.
# In your Nginx server block for static assets
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# In your .htaccess file or Apache <Directory> block
<FilesMatch "\.(css|js|png|jpg|jpeg|gif|ico|svg|woff2)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
Let’s break down the directives:
public: This means the resource can be cached by any cache, not just the user’s browser. This includes CDNs and proxy servers. For static assets you want everyone to have, this is what you use.max-age=31536000: That’s the number of seconds in a year (60 * 60 * 24 * 365). It tells the browser “this is good for 31,536,000 seconds from right now.”immutable: This is the star of the show. It tells the browser that the content of this file will never change while it’s at this URL. This is a powerful signal for the browser to skip the conditional revalidation request it normally makes on a reload. It just uses the cached copy, full stop. No round-trip. Faster for your user, less load on your server. Everyone wins.
Busting the Cache (Or, What to Do When You Do Change Things)
Ah, but here’s the catch. What if you do need to update your main.a1b2c3d4.css file? Simple: you change its filename. The new version gets a new hash (main.e5f6g7h8.css), which is a brand new resource as far as the browser’s cache is concerned. The old file, still cached with its year-long max-age, simply never gets requested again. This is why the filename-changing part of your build process (Webpack, Vite, Parcel, etc.) is so critically important. It’s your cache invalidation strategy, and it works perfectly.
The “Please Check With Me First” Cache (no-cache)
Now, let’s talk about the most misunderstood directive: no-cache. It does not mean “don’t cache.” I know, it’s a terrible name. Blame the committee. What it actually means is “you can cache this, but you must validate it with the server before using it on every subsequent request.” It’s a “show me your papers” approach.
You use this for things that are static-ish but might change unexpectedly outside of your build process, or for critical resources where you absolutely must have the freshest version, but still want the speed benefit of not re-downloading the entire thing if it hasn’t changed.
location /api/health-check {
add_header Cache-Control "no-cache";
}
The browser will store the response, but the next time it needs it, it will send a If-None-Match (with an ETag) or If-Modified-Since request to the server. If the resource is unchanged, the server replies with a lightweight 304 Not Modified instead of re-sending the whole payload. It’s one round-trip, but it saves bandwidth.
The Nuclear Option: no-store
This one means what it says: “Do not store this. At all. Anywhere.” The browser won’t cache it to disk, and it will try to tell any intermediate caches (like a CDN) not to store it either. Use this for highly sensitive, personal data. You would never, ever use this for a static asset like a CSS file. If you see it there, someone has made a very questionable choice.
The Pitfall: The Default is Often the Worst Case
The most common pitfall is doing nothing. If you don’t set a Cache-Control header, the browser is left to its own devices. It will usually use heuristics to guess a freshness time, or it might not cache it at all. This is the worst of all worlds—inconsistent performance and users downloading the same file over and over. You must be explicit. Take control. Your users’ bandwidth bills (and your server load) will thank you.