Homeβ€Ί System Designβ€Ί Browser Cache Explained: How It Works and When It Breaks Your App

Browser Cache Explained: How It Works and When It Breaks Your App

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Components β†’ Topic 16 of 16
Browser cache explained from first principles β€” how it stores, serves, and invalidates resources, plus the exact mistakes that ship stale bugs to production.
πŸ§‘β€πŸ’» Beginner-friendly β€” no prior System Design experience needed
In this tutorial, you'll learn:
  • Content-hash your filenames and serve them with max-age=31536000, immutable β€” this is the only cache strategy that gives you both infinite performance and instant cache busting on deploy, because the URL itself changes when content changes.
  • HTML pages must always be served with Cache-Control: no-cache. It's the index of your app. If users cache stale HTML, they load stale everything β€” even your correctly-busted hashed assets won't help because the HTML still points to old filenames.
  • no-cache and no-store are not synonyms. no-cache caches to disk but revalidates on every use. no-store never touches disk. Confusing these two in production has caused real security incidents where session tokens ended up persisted in browser cache folders.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Imagine you work at a busy diner. Every time a regular customer orders 'the usual,' you could sprint to the kitchen, wait for the chef, and carry it back β€” or you could keep their order on a sticky note behind the counter and hand it straight over. The browser cache is that sticky note. Instead of asking the server for the same CSS file on every page load, the browser keeps a local copy and serves it instantly. The catch? If the chef changes the recipe but your sticky note still says the old thing, you'll keep serving the wrong dish until someone throws out the note.

A junior dev on my team once spent six hours debugging a payment form that was 'obviously broken in production.' Tests passed. The fix was deployed. Users were still hitting the old flow. The culprit wasn't the code β€” it was a JavaScript file sitting in the browser cache with a one-year expiry that nobody had busted. Six hours of engineering time, a near-miss on a P1 incident, and the fix was two characters in an HTTP header.

The browser cache is one of those mechanisms that works so silently when it's right that most developers never learn how it actually works β€” until it bites them. It sits between your users and your servers, storing copies of files locally so the browser doesn't have to re-download them on every visit. Done correctly it makes your app feel instant. Done wrong it ships stale JavaScript to users who will never see your hotfix because their browser is convinced it already has the latest version.

By the end of this article you'll know exactly what gets cached, how the browser decides when to use a cached copy versus fetch a fresh one, how to control that behaviour with HTTP headers, and β€” most importantly β€” the exact techniques used in production to force browsers to pick up new files when you deploy. No hand-waving. No 'it depends.' Concrete mechanics you can act on today.

What the Browser Actually Stores β€” and Where It Puts It

Before you can control the cache, you need to know what's in it. The browser cache is a folder on the user's actual hard drive managed entirely by the browser. Chrome keeps it under your OS profile directory. Firefox has its own. Safari has its own. The user can't easily see it; the browser treats it as an internal implementation detail. But it's real, physical storage on disk.

Every time your browser successfully downloads a resource β€” an HTML file, a CSS stylesheet, a JavaScript bundle, an image, a font β€” it checks the response headers the server sent alongside that file. Based on those headers, it decides: 'Should I keep a copy of this, and for how long?' If the answer is yes, it writes the file to that cache folder along with metadata: the URL it came from, when it was fetched, and the rules governing how long it stays valid.

The next time the browser needs that same URL, it checks the cache first. If a valid copy is there, it uses it without making any network request at all. Zero bytes over the wire. That's why cached pages feel instant β€” there's no network round-trip, no server processing, no waiting. But 'valid copy' is doing a lot of work in that sentence. Whether a cached copy is considered valid depends entirely on those HTTP headers, and that's where most developers have only a fuzzy picture of what's actually happening.

BrowserCacheFlow.systemdesign Β· PLAINTEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// io.thecodeforge β€” System Design tutorial
// Scenario: User visits a checkout page. Browser requests app.js.
// This diagram shows the decision flow the browser runs internally.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     BROWSER CACHE DECISION FLOW                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

User navigates to: https://shop.example.com/checkout
Browser needs:     /static/js/app.v3.bundle.js

                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                         β”‚  Check on-disk  β”‚
                         β”‚   cache store   β”‚
                         β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                  β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                                       β”‚
         CACHE MISS                             CACHE HIT
   (file not found locally)           (file found in cache folder)
              β”‚                                       β”‚
              β–Ό                                       β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  Fetch from network  β”‚           β”‚  Is the cached copy      β”‚
   β”‚  GET /static/js/     β”‚           β”‚  still within its        β”‚
   β”‚  app.v3.bundle.js    β”‚           β”‚  freshness window?       β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚                                    β”‚
              β–Ό                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚                     β”‚
   β”‚  Server responds     β”‚           FRESH               STALE
   β”‚  200 OK              β”‚     (max-age not expired)  (max-age expired)
   β”‚  + Cache-Control     β”‚             β”‚                     β”‚
   β”‚    headers           β”‚             β–Ό                     β–Ό
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚               β”‚ Serve from     β”‚  β”‚ Send conditional    β”‚
              β–Ό               β”‚ disk. No       β”‚  β”‚ request to server:  β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚ network call.  β”‚  β”‚ If-None-Match or    β”‚
   β”‚  Write to cache      β”‚   β”‚ Instant.       β”‚  β”‚ If-Modified-Since   β”‚
   β”‚  store with metadata β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β”‚  (URL, expiry, ETag) β”‚                                  β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                           β”‚                                    β”‚
                                    304 NOT MODIFIED                        200 OK
                               (server confirms copy               (server sends new file)
                                is still current)                          β”‚
                                           β”‚                               β–Ό
                                           β–Ό                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚ Update cache with   β”‚
                               β”‚ Use cached copy.  β”‚            β”‚ new file + new      β”‚
                               β”‚ Only metadata     β”‚            β”‚ expiry metadata     β”‚
                               β”‚ updated on disk.  β”‚            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

// KEY INSIGHT: A 304 response body is empty β€” the server only sends headers.
// This is dramatically cheaper than a full 200 response with file payload.
// On a 500KB JS bundle, that's the difference between 500KB and ~200 bytes.
β–Ά Output
Flow walkthrough β€” first visit:
β†’ Cache MISS on app.v3.bundle.js
β†’ Full network fetch: 200 OK (487 KB transferred)
β†’ Written to cache with max-age=31536000 (1 year)
β†’ Page renders

Flow walkthrough β€” return visit within 1 year:
β†’ Cache HIT on app.v3.bundle.js
β†’ Freshness check: max-age not expired
β†’ Served from disk in ~2ms
β†’ 0 bytes transferred
β†’ Page renders

Flow walkthrough β€” return visit after max-age expires:
β†’ Cache HIT but STALE
β†’ Conditional GET sent: If-None-Match: "a3f82bc"
β†’ Server responds: 304 Not Modified (~200 bytes, no body)
β†’ Cached copy used, metadata refreshed
β†’ Page renders
πŸ”₯
What Actually Lives in the Cache:HTML documents, CSS files, JavaScript bundles, images (PNG, JPG, WebP, SVG), web fonts (WOFF2), JSON API responses (if the server sends appropriate headers), and even video segments. What doesn't get cached by default: POST responses, anything served over HTTP (not HTTPS) with certain header combinations, and anything the server explicitly marks as Cache-Control: no-store.

The HTTP Headers That Run the Show β€” Cache-Control, ETag, and Expires

The browser doesn't make its own decisions about how long to cache something. It reads instructions the server sends in the HTTP response headers. If you control what headers your server sends, you control the cache. This is the part most developers never learn, and it's the difference between a site that deploys cleanly and one that ships stale code to users for weeks.

There are four headers that matter. Cache-Control is the main one β€” it's the directive system that replaced the old Expires header. ETag is a fingerprint of the file's current content. Last-Modified is a timestamp the server stamps on the file. Expires is the legacy predecessor to Cache-Control that still haunts old infrastructure. Here's the mental model: Cache-Control tells the browser how long to trust a cached copy without asking. ETag and Last-Modified are what the browser uses when it needs to ask 'hey, has this changed?' The server compares and answers with either 200 (here's the new version) or 304 (nope, still current, use what you have).

The most important Cache-Control values you'll encounter: max-age=N means 'trust this for N seconds.' no-cache means 'always revalidate before using' β€” confusingly, this doesn't mean 'don't cache at all.' no-store means 'never cache this, ever, not even to disk.' public means CDNs and proxies can also cache it. private means only the end-user's browser should cache it, not shared caches. immutable is the power move β€” it tells the browser 'this URL's content will never change, don't even bother revalidating.' Use that on assets with content-hashed filenames and watch your cache hit rate go through the roof.

CacheHeaderStrategy.systemdesign Β· PLAINTEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// io.thecodeforge β€” System Design tutorial
// Scenario: E-commerce platform serving an HTML page + hashed static assets.
// This shows the header strategy used in production for each asset type.

════════════════════════════════════════════════════════════
 REQUEST: GET /checkout                    (HTML page)
════════════════════════════════════════════════════════════

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
// ↑ 'no-cache' does NOT mean 'don't cache'. It means:
//   'cache it, but revalidate with the server on every visit.'
//   Browser will send If-None-Match header. Server sends 304 if unchanged.
//   For HTML you ALWAYS want this β€” HTML references your other assets
//   and must stay fresh so users pick up new deploys.
ETag: "d4a8f2e3"
// ↑ Content fingerprint. If HTML changes, ETag changes.
//   Browser sends this back as: If-None-Match: "d4a8f2e3"
//   Server compares, responds 304 or 200.


════════════════════════════════════════════════════════════
 REQUEST: GET /static/js/app.a7f3c9b1.bundle.js  (hashed JS)
════════════════════════════════════════════════════════════

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
// ↑ public:      CDNs and shared caches can store this (fine for static assets)
// ↑ max-age=31536000: cache for exactly 1 year (365 days Γ— 24h Γ— 60m Γ— 60s)
// ↑ immutable:   browser will NEVER revalidate this URL β€” not even when expired
//   This is safe ONLY because the filename contains a content hash (a7f3c9b1).
//   When code changes, the bundler generates a NEW filename with a NEW hash.
//   The old URL is simply never requested again.


════════════════════════════════════════════════════════════
 REQUEST: GET /static/css/styles.c2d8e4f0.css  (hashed CSS)
════════════════════════════════════════════════════════════

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=31536000, immutable
// Same strategy as JS. Hashed filename = safe to cache forever.


════════════════════════════════════════════════════════════
 REQUEST: GET /api/cart/items            (API response)
════════════════════════════════════════════════════════════

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
// ↑ private:   never let a CDN or proxy cache user-specific data
// ↑ no-store:  don't write this to disk AT ALL β€” cart data is user-specific
//              and changes constantly. Caching it causes users to see
//              each other's data or stale totals. This has burned people.


════════════════════════════════════════════════════════════
 REQUEST: GET /images/hero-banner.webp   (marketing image, no hash)
════════════════════════════════════════════════════════════

HTTP/1.1 200 OK
Content-Type: image/webp
Cache-Control: public, max-age=86400
Last-Modified: Tue, 08 Jul 2025 09:00:00 GMT
ETag: "img-hero-v2"
// ↑ max-age=86400: cache for 24 hours (no content hash in filename,
//   so we can't use immutable β€” the URL might serve new content)
// ↑ After 24h, browser sends: If-Modified-Since + If-None-Match
// ↑ Server compares timestamps/ETags and responds 200 or 304


════════════════════════════════════════════════════════════
 THE GOLDEN RULE OF CACHE HEADER STRATEGY
════════════════════════════════════════════════════════════

  URLs that CHANGE content but keep the same path:
    β†’ Use Cache-Control: no-cache + ETag
    β†’ Revalidation ensures freshness. Fast 304s keep it cheap.

  URLs that NEVER change content (content-hashed filenames):
    β†’ Use Cache-Control: public, max-age=31536000, immutable
    β†’ Cache forever. When content changes, URL changes.

  User-specific or sensitive data:
    β†’ Use Cache-Control: private, no-store
    β†’ Never cache. Never share between users.
β–Ά Output
Simulated cache behaviour across 3 visits to /checkout:

Visit 1 (cold cache):
GET /checkout β†’ 200 OK (8.2 KB transferred)
GET /static/js/app.a7f3c9b1... β†’ 200 OK (487 KB transferred)
GET /static/css/styles.c2d8... β†’ 200 OK (62 KB transferred)
GET /images/hero-banner.webp β†’ 200 OK (94 KB transferred)
Total transferred: ~651 KB | Load time: 1.8s

Visit 2 (cache warm, same day):
GET /checkout β†’ 304 Not Modified (headers only, ~180B)
GET /static/js/app.a7f3c9b1... β†’ [from cache] (0B, 2ms)
GET /static/css/styles.c2d8... β†’ [from cache] (0B, 2ms)
GET /images/hero-banner.webp β†’ [from cache] (0B, 2ms)
Total transferred: ~180 bytes | Load time: 210ms

Visit 3 (after deploy β€” new JS hash app.b9e1f2a3...):
GET /checkout β†’ 304 Not Modified (headers only)
GET /static/js/app.b9e1f2a3... β†’ 200 OK (491 KB) ← new hash, fresh fetch
GET /static/css/styles.c2d8... β†’ [from cache] (0B, 2ms) ← unchanged
GET /images/hero-banner.webp β†’ [from cache] (0B, 2ms)
Total transferred: ~491 KB | Users get new code immediately.
⚠️
Production Trap: 'no-cache' Is NOT 'no-store'Cache-Control: no-cache still caches the file on disk β€” it just forces a revalidation request each time. Cache-Control: no-store never touches disk at all. I've seen teams slap no-cache on session tokens thinking they were protecting them, then find those tokens sitting in the browser's cache folder in plaintext. For anything sensitive β€” auth tokens, user data, payment info β€” use no-store, not no-cache.

Cache Busting: How to Force Users Off Stale Files After a Deploy

Here's the situation that breaks things: you deploy a hotfix, your monitoring confirms the new code is on the server, but users are still reporting the old broken behaviour. Your CS team is getting tickets. Your boss is pinging you on Slack. The server is serving the right file β€” but the browser is ignoring the server entirely because it has a cached copy it thinks is still valid.

This is cache invalidation, and it's one of the two hard problems in computer science for a reason. You can't reach into a user's browser and delete a file. You can't send a push notification saying 'please clear your cache.' You have essentially zero control over what's currently sitting in millions of users' browsers right now. The only lever you have is the URL. Browsers treat a different URL as a completely different resource. A file at /app.js and a file at /app.js?v=2 are, to the browser, two entirely separate things. That's the mechanism.

The professional solution is content hashing, and every serious frontend build pipeline does this. Your bundler (Webpack, Vite, Parcel, esbuild) generates a hash from the actual file contents and embeds it in the filename: app.a7f3c9b1.bundle.js. If one byte of the source changes, the hash changes, and you get a completely new filename. The old URL simply stops being requested. No cache to bust β€” there's nothing in the cache for the new URL yet. The HTML page (which you serve with no-cache so it always revalidates) references the new filename, and users get the new file automatically on their next visit, zero intervention required.

CacheBustingStrategies.systemdesign Β· PLAINTEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// io.thecodeforge β€” System Design tutorial
// Scenario: SaaS dashboard. Frontend team deploys a critical bugfix.
// Comparing 3 cache-busting approaches by effectiveness and production safety.

════════════════════════════════════════════════════════════
 APPROACH 1: Query String Versioning  (fragile, not recommended)
════════════════════════════════════════════════════════════

Deploy 1:  <script src="/static/js/dashboard.js?v=1"></script>
Deploy 2:  <script src="/static/js/dashboard.js?v=2"></script>

Problems:
  βœ— Some CDNs and shared caches IGNORE query strings and cache
    the base URL only. Users behind a corporate proxy may never
    see version 2 even though you updated the query param.
  βœ— You must manually increment the version number.
    Someone WILL forget. It has happened on every team I've seen use this.
  βœ— The file itself hasn't changed name β€” old CDN nodes may serve
    cached v=1 content for the v=2 URL.


════════════════════════════════════════════════════════════
 APPROACH 2: Content-Hash Filename  (production standard)
════════════════════════════════════════════════════════════

Deploy 1 β€” source unchanged:
  /static/js/dashboard.a7f3c9b1.bundle.js
  /static/css/styles.c2d8e4f0.css
  /index.html β†’ references these exact filenames
  Cache-Control on .js/.css: public, max-age=31536000, immutable
  Cache-Control on index.html: no-cache

Deploy 2 β€” one JS file changed:
  /static/js/dashboard.b9e1f2a3.bundle.js   ← new hash (content changed)
  /static/css/styles.c2d8e4f0.css           ← SAME hash (content unchanged)
  /index.html β†’ references new dashboard.b9e1f2a3.bundle.js

What happens on user's next visit:
  Step 1: Browser fetches /index.html
          β†’ Sends If-None-Match with old ETag
          β†’ Server: content changed (new script reference) β†’ 200 OK
          β†’ Browser gets updated HTML pointing to new JS filename

  Step 2: Browser sees /static/js/dashboard.b9e1f2a3.bundle.js
          β†’ Cache MISS (this URL has never been fetched before)
          β†’ Full download: 200 OK
          β†’ Cached with max-age=31536000, immutable

  Step 3: Browser sees /static/css/styles.c2d8e4f0.css
          β†’ Cache HIT β€” same URL, still within max-age, immutable
          β†’ Served from disk in 2ms, 0 bytes transferred

  Result: User gets new JS instantly on next page load.
          CSS served from cache (no wasted bandwidth).
          Zero manual intervention from the dev team.

How your bundler does this automatically (Vite config example):
  build: {
    rollupOptions: {
      output: {
        entryFileNames:  'assets/[name].[hash].js',
        chunkFileNames:  'assets/[name].[hash].js',
        assetFileNames:  'assets/[name].[hash][extname]',
      }
    }
  }
  // [hash] is computed from file contents by Rollup/Vite automatically.
  // Same source = same hash every build. Deterministic.


════════════════════════════════════════════════════════════
 APPROACH 3: Cache-Control: no-store on everything  (nuclear option)
════════════════════════════════════════════════════════════

  Cache-Control: no-store on all assets

What this does: forces a full re-download of every resource on every page load.

Problems:
  βœ— Users on slow connections re-download your entire 2MB app bundle
    every single page navigation. This tanks performance metrics.
  βœ— Your origin servers absorb 100% of traffic. No cache offloading.
    At scale this costs real money and causes load spikes on deploys.
  βœ— Core Web Vitals tank. LCP and FCP suffer measurably.

  Use this ONLY for: sensitive user-specific API responses,
  auth tokens, or data you genuinely must never persist to disk.
  Never use it as a caching strategy for static assets.


════════════════════════════════════════════════════════════
 DECISION MATRIX
════════════════════════════════════════════════════════════

  Asset type                    | Strategy
  ──────────────────────────────┼─────────────────────────────────────
  HTML pages                    | Cache-Control: no-cache + ETag
  JS/CSS (content-hashed name)  | Cache-Control: public, max-age=31536000, immutable
  Images (with hash in name)    | Cache-Control: public, max-age=31536000, immutable
  Images (without hash)         | Cache-Control: public, max-age=86400 + ETag
  API responses (public data)   | Cache-Control: public, max-age=60 (short TTL)
  API responses (user-specific) | Cache-Control: private, no-store
  Auth tokens / session data    | Cache-Control: private, no-store
β–Ά Output
Deploy scenario β€” before and after content-hash busting:

BEFORE deploy (cached state in user's browser):
Cached: dashboard.a7f3c9b1.bundle.js (expires in 11 months)
Cached: styles.c2d8e4f0.css (expires in 11 months)
Cached: index.html (no-cache β€” will revalidate)

AFTER deploy (user visits next page):
index.html β†’ revalidates β†’ 200 OK (new content, 0.3ms)
dashboard.b9e1f2a3.bundle.js β†’ MISS β†’ 200 OK (491KB fetched)
styles.c2d8e4f0.css β†’ HIT β†’ 0 bytes (from disk)

User sees new code: immediately on next visit
Bandwidth saved: 62KB (unchanged CSS served from cache)
Dev intervention: zero β€” bundler handled it all
⚠️
Senior Shortcut: The 'Immutable' Flag Is UnderusedAdd 'immutable' to Cache-Control on any content-hashed asset. Without it, some browsers still send a revalidation request when the tab is refreshed even if max-age hasn't expired β€” they're being cautious. With 'immutable', the browser skips that revalidation entirely. On a page with 40 static assets, that's 40 fewer round-trips on every hard refresh. Firefox has supported this since 2017. Chrome since 2018. Safari since 2019. Use it.

When the Cache Actively Breaks Things β€” Real Failure Modes

Most of the time the cache is your friend. But there's a specific class of production bugs that only exist because of caching, and they're maddening to debug because everything looks fine on the server side.

The worst one I've personally dealt with: a deploy that updated both an API response schema and the JavaScript that consumes it. The JS file had a content hash so it busted correctly. But the API endpoint was cached by a CDN with a 5-minute TTL. For 5 minutes after deploy, some users got the new JavaScript hitting the old API schema. The new code expected a lineItems array. The old API returned line_items. The cart page threw a runtime TypeError and users couldn't check out. Five minutes felt like an eternity. The fix: deploy API changes in a backwards-compatible way β€” always β€” and keep your CDN TTLs on API responses short. 60 seconds maximum. Usually less.

Another one: service workers. A service worker is JavaScript that runs in the background and can intercept every network request the page makes, including serving resources from its own cache. When it's configured well, it makes your app work offline. When it's configured wrong β€” particularly with an overly aggressive caching strategy on the service worker script itself β€” it can get stuck and prevent users from ever getting updates. I've seen this on a retail site where the service worker script was accidentally cached with a 1-year max-age. The service worker would intercept every update check and serve the old version of itself. The only fix was a user manually clearing application storage from DevTools. You can't push that fix to users who can't load your updated code. Think about that.

CacheFailureModes.systemdesign Β· PLAINTEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// io.thecodeforge β€” System Design tutorial
// Scenario: Diagnosing and fixing 3 real cache-related production failures.
// Each block shows: what went wrong β†’ why β†’ exact fix.

════════════════════════════════════════════════════════════
 FAILURE 1: Split-brain deploy β€” new JS hits old API
════════════════════════════════════════════════════════════

Symptom:
  TypeError: Cannot read properties of undefined (reading 'map')
  console shows: cartData.lineItems is undefined
  Affects: ~30% of users for exactly 5 minutes after every deploy
  Passes in: all test environments (tests don't use CDN cache)

Root cause:
  API /api/cart response cached by CDN with max-age=300 (5 minutes)
  New JS expects: { lineItems: [...] }   ← renamed field in new API
  CDN still serving: { line_items: [...] } ← old field name
  New frontend code + old API response = runtime crash

Fix:
  Option A β€” Never rename fields, only ADD new ones (backwards compatible)
    Old response:  { line_items: [...], lineItems: [...] }  ← both fields
    New JS uses lineItems. Old JS uses line_items. Both work during rollout.
    Deprecate line_items in a future deploy after cache expires.

  Option B β€” Reduce CDN TTL on API responses that change with deploys
    Cache-Control: public, max-age=30   (30 seconds, not 5 minutes)
    Acceptable stale window drops from 5 minutes to 30 seconds.

  Option C β€” Use CDN cache invalidation API on deploy
    Most CDNs (CloudFront, Fastly, Cloudflare) expose an API to
    explicitly purge cached paths on deploy. Wire this into your
    CI/CD pipeline. Run it before traffic shifts to new containers.


════════════════════════════════════════════════════════════
 FAILURE 2: Broken HTML served from cache after botched deploy
════════════════════════════════════════════════════════════

Symptom:
  Deploy rolled back. Server is serving correct HTML again.
  But ~15% of users still see broken page for hours.

Root cause:
  index.html was served with Cache-Control: max-age=3600
  (someone 'optimised' HTML caching without understanding the risk)
  Those users cached the broken HTML locally during the bad deploy window.
  Browser serves it from disk for up to 1 hour. Rollback invisible to them.

Fix:
  HTML pages must NEVER have a long max-age.
  Use: Cache-Control: no-cache
  This forces a revalidation on every visit.
  The 304 Not Modified response is cheap β€” usually < 200 bytes.
  The cost of a 304 is vastly outweighed by the ability to recover
  from a bad deploy without users being stuck on broken HTML for an hour.


════════════════════════════════════════════════════════════
 FAILURE 3: Service worker serves stale app indefinitely
════════════════════════════════════════════════════════════

Symptom:
  Deployed critical security patch to auth flow.
  New service worker registers. Old app keeps loading for most users.
  Users who explicitly hit Ctrl+Shift+R see the new version.
  Normal visits: still old code, indefinitely.

Root cause:
  Service worker script (sw.js) was cached by the HTTP cache with
  Cache-Control: max-age=86400 (24 hours).
  Browser checks for a new service worker on each visit β€” but it
  was getting the old sw.js from the HTTP cache, not the server.
  The browser thought: 'sw.js hasn't changed' (byte-for-byte comparison
  of cached file vs cached file). Old service worker stayed active.

Fix:
  Service worker scripts must NEVER be cached by the HTTP cache.
  Cache-Control: no-store on sw.js specifically.
  The browser has its own 24-hour update check for service workers
  (separate from the HTTP cache) β€” let that mechanism work.
  Don't layer HTTP caching on top of it.

  Nginx config for this:
    location = /sw.js {
      add_header Cache-Control "no-store, no-cache, must-revalidate";
      proxy_pass http://app_upstream;
    }
    # Every other static asset keeps long max-age + immutable


════════════════════════════════════════════════════════════
 QUICK DIAGNOSTIC β€” when users report 'seeing old content'
════════════════════════════════════════════════════════════

  Step 1: Open Network tab in DevTools
          Filter by the affected resource (JS file, HTML, image)
          Check 'Size' column:
            '(disk cache)'  β†’ browser didn't even ask the server
            '(memory cache)' β†’ in-memory, not hit network
            A number in KB   β†’ came from network

  Step 2: Check Response Headers of the cached resource
          Look for Cache-Control, Expires, ETag, Last-Modified
          How long is max-age? Has it expired?

  Step 3: Check if the filename has a content hash
          app.a7f3c9b1.bundle.js β†’ hashed, should be immutable
          app.bundle.js          β†’ not hashed, relies on max-age/ETag

  Step 4: Hard refresh (Ctrl+Shift+R) to bypass cache
          If this fixes it: caching is the problem
          If this doesn't fix it: the issue is server-side
β–Ά Output
Failure 1 timeline:
12:00:00 β€” Deploy starts. New containers come online.
12:00:15 β€” New JS bundle (lineItems) starts serving.
12:00:15 β€” CDN still has old API response cached ({line_items}).
12:00:15 to 12:05:15 β€” TypeError on checkout for users who got new JS.
12:05:16 β€” CDN cache expires. New API response propagates.
12:05:16 β€” Error rate drops to zero.
Impact: 5 minutes of checkout failures per deploy.

Failure 2 timeline:
13:00:00 β€” Bad deploy. Broken HTML cached by some browsers (max-age=3600).
13:02:00 β€” Rollback complete. Server healthy.
13:02:00 β€” Users with cached broken HTML: still broken.
14:00:00 β€” max-age expires. Those users finally get good HTML.
Impact: 58 minutes of broken experience post-rollback.

Failure 3:
Post-deploy: 12% of users on updated service worker.
88% of users: old service worker, HTTP cache serving stale sw.js.
Resolution: required manual cache-busting comms to affected users.
⚠️
Never Do This: Long max-age on HTMLSetting Cache-Control: max-age=3600 (or longer) on HTML pages is the single most dangerous caching mistake in production. Your HTML is the manifest β€” it points to all your other assets. If users cache a stale HTML page, they'll load stale JS and CSS even after your content-hashed assets deployed correctly. Always serve HTML with no-cache. The 304 revalidation cost is negligible. The recovery from a bad deploy without this is not.
Cache-Control DirectiveBrowser Caches to Disk?Network Request on Each Visit?Safe for User Data?Best Used For
max-age=31536000, immutableYesNo (until URL changes)Only for public assetsContent-hashed JS, CSS, fonts, images
no-cacheYesYes β€” but 304 if unchangedYes (file stays fresh)HTML pages, non-hashed assets
no-storeNoYes β€” full fetch every timeYes β€” nothing persistedAuth tokens, user-specific API responses
private, max-age=300Yes (browser only)No (within 5 min window)Only if truly privateUser-specific but non-sensitive API data
public, max-age=60Yes (browser + CDN)No (within 60 sec window)No β€” avoid for user dataPublic API endpoints with acceptable staleness
must-revalidateYesYes β€” after max-age expiresYesPaired with max-age to enforce strict expiry
stale-while-revalidate=30YesServes stale, fetches in backgroundDepends on contentNon-critical UI where slight staleness is acceptable

🎯 Key Takeaways

  • Content-hash your filenames and serve them with max-age=31536000, immutable β€” this is the only cache strategy that gives you both infinite performance and instant cache busting on deploy, because the URL itself changes when content changes.
  • HTML pages must always be served with Cache-Control: no-cache. It's the index of your app. If users cache stale HTML, they load stale everything β€” even your correctly-busted hashed assets won't help because the HTML still points to old filenames.
  • no-cache and no-store are not synonyms. no-cache caches to disk but revalidates on every use. no-store never touches disk. Confusing these two in production has caused real security incidents where session tokens ended up persisted in browser cache folders.
  • The browser cache is entirely controlled by HTTP response headers from your server β€” you have no direct access to what's in a user's cache once it's there. Your only lever is the URL. Change the URL and the cache is irrelevant. That's the entire theory behind content hashing.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Deploying new JS and API schema changes simultaneously without backwards compatibility β€” symptom: TypeError in browser console affecting a percentage of users for exactly the duration of your CDN TTL β€” fix: make API changes additive (add new fields alongside old ones), reduce CDN TTL on API endpoints to max-age=30, and wire CDN cache purge into your CI/CD deploy step.
  • βœ•Mistake 2: Setting Cache-Control: max-age=3600 or longer on HTML pages β€” symptom: users report 'still seeing the old site' hours after deploy, Ctrl+Shift+R fixes it for them individually but you can't force this for all users β€” fix: always serve HTML with Cache-Control: no-cache and a strong ETag; the 304 revalidation costs microseconds but gives you instant recovery from bad deploys.
  • βœ•Mistake 3: Caching the service worker script (sw.js) with a long max-age via HTTP headers β€” symptom: new service worker registers on your server but most users never receive it; bypass requires users to open DevTools > Application > Clear Storage β€” fix: add a specific Nginx or CDN rule serving sw.js with Cache-Control: no-store, keeping the browser's built-in 24-hour SW update check as the only caching layer.
  • βœ•Mistake 4: Using Cache-Control: no-cache thinking it means 'never cache this' β€” symptom: sensitive data (session tokens, user PII) found in browser cache folder in plaintext during a security audit β€” fix: use Cache-Control: private, no-store for anything sensitive; no-cache still writes to disk, no-store never does.
  • βœ•Mistake 5: Not setting the immutable directive on content-hashed assets β€” symptom: users doing a hard refresh (Ctrl+Shift+R) trigger full re-downloads of JS and CSS bundles that haven't changed, visible as unnecessary 200 responses on hashed filenames in the Network tab β€” fix: add immutable to Cache-Control on all assets whose filenames contain a content hash; it eliminates revalidation requests even on forced refreshes.

Interview Questions on This Topic

  • QA user reports they're still seeing a bug you deployed a fix for 3 hours ago. Your server logs confirm the new code is live. Walk me through exactly how you'd diagnose whether this is a browser cache issue, a CDN cache issue, or something else β€” and what's your fix for each case.
  • QYour team wants to cache API responses on the CDN for performance, but the API returns user-specific data mixed with public data in the same endpoint. How do you handle caching safely, and what header specifically controls whether a CDN is allowed to cache a response at all?
  • QYou're doing a zero-downtime deploy where both the old and new version of your app run simultaneously for 90 seconds during rollover. Your JS bundle and your API share a renamed field. What breaks, for which users, for how long β€” and what's the architectural change that prevents this class of problem permanently?
  • QWhat's the difference between Cache-Control: no-cache and Cache-Control: no-store, and give me a concrete production scenario where using the wrong one causes a security incident.

Frequently Asked Questions

Why does clearing my browser cache fix so many random website problems?

Because the browser was serving you a stale local copy of a file that had changed on the server β€” and since the cached copy hadn't expired yet, the browser never asked the server for an update. Clearing the cache deletes all those local copies, forcing the browser to fetch everything fresh from the network on the next visit. It's a blunt instrument. The proper fix is on the server side: correct Cache-Control headers and content-hashed filenames so users get updates automatically without ever needing to clear their cache manually.

What's the difference between browser cache and cookies?

Completely different mechanisms with different purposes. The browser cache stores copies of files β€” JavaScript, CSS, images, HTML β€” to avoid re-downloading them. Cookies store small pieces of text data that get sent back to the server with every request, typically used for session identifiers and user preferences. You can't store a JavaScript file in a cookie, and cookies don't speed up file loading. The rule: cache is for performance, cookies are for state.

How do I force all users to reload a JavaScript file I just updated in production?

The reliable way is to change the file's URL β€” specifically by adding a content hash to the filename (e.g. app.a7f3c9b1.bundle.js becomes app.b9e1f2a3.bundle.js after a change). Every modern bundler (Vite, Webpack, Parcel) does this automatically. Then serve your HTML with Cache-Control: no-cache so browsers always fetch the latest HTML pointing to the new filename. The old cached JS file becomes irrelevant β€” nothing references its URL anymore. Appending a query string (?v=2) is a weaker fallback that some CDNs ignore.

Can a CDN serve a different cached response to different users for the same URL, and what header controls this?

Yes, through the Vary header. Cache-Control: public tells CDNs they can cache a response, but Vary: Accept-Language tells the CDN to maintain separate cached copies for each language variant. So French users get a cached French response and English users get a cached English response from the same CDN node, for the same URL. The dangerous version is Vary: Cookie or Vary: Authorization β€” these tell CDNs to cache separately per user, which can explode your CDN cache storage and effectively kills the performance benefit. For user-specific data, skip the CDN entirely and use Cache-Control: private so only the browser caches it locally.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousHTTP 500 Internal Server Error: Causes, Debugging and Fixes
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged