Cache-Control Headers: Why Your CSS Won’t Update

Understand Cache-Control headers and why users see stale CSS and JS. Learn cache busting, the right header per file type, and a practical caching checklist.

You pushed a CSS change, but some users still see the old design. The usual cause is HTTP caching working exactly as configured, just not the way you intended. This article explains how Cache-Control works, why stale files happen, and how to set headers so users always get fresh content without giving up speed.

What Cache-Control does

When a browser downloads a file, the server can attach a Cache-Control header telling the browser how long it may reuse that file without asking again. This is why a returning visitor loads your page faster: the browser serves CSS, JavaScript, and images from its local cache instead of re-downloading them.

The trade-off is obvious once you hit it. If you told the browser to cache style.css for a year, and you change that file, the browser keeps using its cached copy until the year passes or the cache is cleared. The user sees old styles.

The directives you actually use

  • max-age=SECONDS — how long the file is considered fresh. max-age=31536000 is one year.
  • no-cache — the browser may store the file but must revalidate with the server before using it. It does not mean “do not store.”
  • no-store — do not cache at all. Use for private or sensitive responses.
  • immutable — tells the browser the file will never change, so it should not revalidate even on reload.
  • public / private — whether shared caches (like a CDN) may store it, or only the user’s browser.

The pattern that solves stale files: cache busting

The reliable approach is to cache aggressively but change the filename whenever the content changes. This is called cache busting or fingerprinting.

Instead of style.css, your build outputs style.a1b2c3.css, where the hash comes from the file content. When you change the CSS, the hash changes, so the filename changes, so the browser treats it as a brand new file and downloads it. The old cached copy is simply never requested again.

This lets you set a one-year max-age with confidence. You get maximum caching speed and instant updates, because updates arrive as new URLs.

A real scenario

A team reported that after every deploy, roughly a third of users complained about a broken layout for a day, then it fixed itself. Their server sent Cache-Control: max-age=86400 on main.css, a plain filename. Each deploy changed the file content but not the name, so browsers kept the previous version for up to 24 hours.

The fix had two parts. First, their build tool added content hashes to filenames. Second, they split headers by type: hashed assets got max-age=31536000, immutable, while the HTML document got no-cache so the browser always revalidates and picks up the newest asset filenames. After that, deploys took effect immediately for everyone.

Which header for which file

File type Recommended Cache-Control
HTML documents no-cache (revalidate every time)
Hashed CSS / JS / fonts max-age=31536000, immutable
Non-hashed CSS / JS short max-age or no-cache
User-specific / sensitive data no-store, private

Common mistakes and how to fix them

  • Long max-age on plain filenames. This is the classic stale-CSS bug. Fix: add content hashes to asset filenames before caching them for a long time.
  • Assuming no-cache means no caching. It means revalidate, not skip storage. Fix: use no-store when you truly want nothing cached.
  • Caching the HTML document for a long time. If the HTML is cached, users never see new asset URLs. Fix: keep HTML on no-cache or a very short max-age.
  • Forgetting the CDN layer. A CDN has its own cache. Even correct browser headers will not help if the CDN serves an old file. Fix: purge the CDN on deploy or rely on hashed URLs the CDN treats as new.
  • Caching authenticated pages publicly. Using public on private data can leak it into shared caches. Fix: mark private responses with private or no-store.

Action checklist

  • Enable content hashing for CSS, JS, and fonts in your build tool.
  • Set hashed assets to max-age=31536000, immutable.
  • Set HTML documents to no-cache.
  • Use no-store for sensitive or user-specific responses.
  • Purge or verify your CDN cache on each deploy.
  • Confirm headers in your browser’s network panel after deploying.

Conclusion and next step

Stale files are almost always a caching configuration choice, not a browser bug. Cache hashed assets aggressively, keep HTML fresh, and let changed content arrive as new URLs. Next step: open your network panel, check the Cache-Control header on your CSS, and confirm your filenames include a content hash.

FAQ

Why do only some users see the old version?

Caching is per browser. Users who visited recently hold a cached copy, while new or cache-cleared users get the fresh file. That is why the problem looks random.

What is the difference between Cache-Control and ETag?

Cache-Control sets how long a file is fresh. An ETag is a fingerprint the browser sends back to ask “has this changed?” during revalidation. They work together: Cache-Control controls freshness, ETag controls efficient revalidation.

Is a hard refresh a real fix?

It only helps the one person who does it. Telling every user to hard refresh is not a solution. Cache busting fixes it for everyone automatically.

Should I ever use no-store on assets?

Rarely for static assets, since it removes all caching benefit. Reserve no-store for private or sensitive responses that must never be reused.

References

  • MDN Web Docs: the Cache-Control HTTP header and its directives.
  • MDN Web Docs: HTTP caching, ETag, and revalidation.

Muc luc bai viet