Senior 7 min · March 29, 2026

Browser Cache — Why Your Hotfix Isn't Reaching Users

CDN served stale API for 5 min while new JS loaded instantly — field rename mismatch crashed checkout.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • The browser cache stores copies of resources on disk, serving them without network requests for faster load times.
  • It relies on HTTP headers like Cache-Control, ETag, and Last-Modified to decide freshness and reuse.
  • Content-hashed filenames (e.g., app.a7f3c9b1.js) enable safe long-term caching with instant cache busting on deploy.
  • A 304 Not Modified response is dramatically cheaper than a full 200 — ~200 bytes vs the full resource size.
  • The most common production failure: caching HTML with a long max-age, which locks users on stale page manifests.
Plain-English First

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.systemdesignPLAINTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 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               STALE200 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.
Production Insight
The disk cache is managed entirely by the browser — you can't directly read or delete it from the server.
That means once a file is cached with a long max-age, you lose control until that expiry passes.
Rule: The only reliable cache-busting lever you have is the URL — change it when content changes.
Key Takeaway
The browser cache is a black box on disk that only the browser controls.
Your power is limited to the HTTP headers you send.
If you need to force re-download, change the URL.

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.systemdesignPLAINTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 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 thisHTML 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 + ETagRevalidation 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.
Production Insight
The 'no-cache' directive is one of the most misunderstood headers in production.
It writes to disk and only validates freshness with the server — it's not a security measure.
If you need to store nothing, use no-store. Period.
Key Takeaway
Cache-Control directives are not synonyms: no-cache caches and revalidates, no-store caches nothing.
Using no-cache for sensitive data is a security incident waiting to happen.

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.systemdesignPLAINTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// 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 ETagServer: content changed (new script reference) → 200 OKBrowser 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 OKCached 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 Underused
Add '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.
Production Insight
The immutable directive is free performance — but it only applies to content-hashed filenames.
Never use immutable on a non-hashed asset; you'll lock users into stale content until the max-age expires.
Rule: hash your filenames, then cache them forever with immutable.
Key Takeaway
Content hashing is the only cache-busting strategy that scales.
Change the URL when content changes — the cache is irrelevant for the old URL.

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.systemdesignPLAINTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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: [...]
Never Do This: Long max-age on HTML
Setting 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.
Production Insight
CDN caches and browser caches are independent — a stale CDN response can break perfectly busted front-end code.
Deploying API schema changes alongside front-end changes without backwards compatibility is a recipe for split-brain failures.
Rule: Always deploy additive changes first, remove old fields after the cache TTL has expired.
Key Takeaway
Cache inconsistency between different resource types (JS vs API) is a silent production breaker.
Backwards-compatible API changes and short CDN TTLs are your safety net.

CDN Caching and the Vary Header: When the Browser Isn't the Only Cache

So far we've focused on the browser's cache. But in production, you also have CDN caches — intermediate servers between your users and your origin that store cached responses to serve a broad geographic audience. The browser cache is per-user. The CDN cache is shared across many users for the same URL. And that introduces a subtle set of problems that only appear at scale.

The key header that controls shared caching is Cache-Control: public. When you send public, you're telling CDNs and proxies: 'You may cache this response and serve it to other users.' If you omit public or send private, CDNs must NOT cache it — only the end-user's browser may. That's the distinction: public for shared cache, private for browser-only.

Then there's the Vary header. It tells caches that the response might differ depending on some request header. For example

CDNCachingStrategies.systemdesignPLAINTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// io.thecodeforge — System Design tutorial
// Scenario: Global e-commerce site with CDN. How to handle Vary and cache invalidation.

════════════════════════════════════════════════════════════
 VARY HEADER EXAMPLES
════════════════════════════════════════════════════════════

// Good: vary on encoding (standard for compressed responses)
  Cache-Control: public, max-age=31536000
  Vary: Accept-Encoding
  // CDN stores separate entries for gzip vs non-gzip.
  // Typically the CDN handles this transparently, but explicit is safer.

// Good: vary on language for localized public pages
  Cache-Control: public, max-age=3600
  Vary: Accept-Language
  // CDN caches 'en' version and 'fr' version separately.
  // French users get French cached HTML, English users get English.
  // Works fine if the number of languages is small (< 10).

// Bad: vary on Cookie or Authorization for user-specific data
  Cache-Control: public
  Vary: Cookie
  // Every unique Cookie value creates a new cache entry.
  // For millions of users, this completely fills the CDN cache.
  // Old entries get evicted quickly, reducing hit rate to near zero.
  // Better: use Cache-Control: private so the CDN never caches it.

// Better for user-specific APIs:
  Cache-Control: private, no-store
  // No Vary needed — CDN is prohibited from caching altogether.


════════════════════════════════════════════════════════════
 CI/CD PIPELINE: CDN CACHE PURGE ON DEPLOY
════════════════════════════════════════════════════════════

  # Example using AWS CloudFront create-invalidation
  # Run after uploading new assets to S3/origin

  aws cloudfront create-invalidation \n    --distribution-id E1A2B3C4D5E6F7 \n    --paths "/index.html" "/static/js/*" "/static/css/*"

  # For Fastly:
  # curl -X POST -H "Fastly-Key: $API_KEY" \n  #   https://api.fastly.com/service/$SERVICE_ID/purge?path=/static

  # For Cloudflare:
  # curl -X POST -H "Authorization: Bearer $API_TOKEN" \n  #   https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache \n  #   -d '{"files":["https://example.com/static/js/*"]}'

  // Run this invocation AFTER your deploy completes but BEFORE you
  // declare the deploy successful in your monitoring.


════════════════════════════════════════════════════════════
 CDN CACHE DECISION FLOW
════════════════════════════════════════════════════════════

  User request reaches CDN edge node:
  ┌─────────────────────────────────────────────────────────────┐
  │  Does the CDN have a cached response for this URL?         │
  │  (matching cache key = URL + Vary headers)                  │
  └──────────┬──────────────────────────────────────────────────┘
             │
       ┌─────┴─────┐
       │           │
      HIT         MISS
       │           │
       ▼           ▼
  ┌──────────┐  ┌──────────────────┐
  │ Serve    │  │ Forward to       │
  │ cached   │  │ origin server    │
  │ response │  └────────┬─────────┘
  └──────────┘           │
                         ▼
                  ┌──────────────────────┐
                  │ Origin responds:     │
                  │ 200 OK + Cache-Control│
                  └──────────┬───────────┘
                             │
                    ┌────────┴────────┐
                    │                 │
              Cache-Control:    Cache-Control:
              public?           private?
                    │                 │
                    ▼                 ▼
             ┌─────────────┐  ┌──────────────┐
             │ CDN caches  │  │ Pass through │
             │ and serves  │  │ to user, not │
             │ to all      │  │ cached on CDN│
             └─────────────┘  └──────────────┘
Output
Scenario: User requests /static/js/app.a7f3c9b1.js via CDN
Cold cache (first request from any user):
→ Edge node: MISS
→ Forwards to origin: 200 OK
CDN Cache vs Browser Cache: Key Differences
The CDN cache is a shared cache that serves many users. The browser cache is per-user. CDN caches have configurable TTLs and support explicit invalidation (purge) via API. Browser caches do not — you cannot remotely delete a file from a user's browser. Both can cause stale content, but CDN stale content affects a broader audience instantly after deploy. That's why CDN cache purge is a critical deployment step, not an afterthought.
Production Insight
Vary: Cookie is frequently misused, causing CDN cache efficiency to plummet.
If you need user-specific caching, use Cache-Control: private and skip the CDN.
The CDN is for public, shared resources — not personalization.
Key Takeaway
CDN caches are shared and require explicit invalidation on deploy.
Use Vary sparingly — and never Vary: Cookie for personalized content.
● Production incidentPOST-MORTEMseverity: high

Stale CDN Cache After a Schema Change Crashed Checkout for 5 Minutes

Symptom
Users on the checkout page saw a white screen with a console error: 'Cannot read properties of undefined (reading 'map')'. The error hit 30% of traffic and lasted exactly 5 minutes after every deploy. All test environments passed because they bypassed the CDN.
Assumption
The team assumed that because the JavaScript bundle was content-hashed and busted correctly, the whole front-end would be consistent after deploy. They didn't consider that the API response was cached separately by the CDN.
Root cause
The API endpoint /api/cart was served with Cache-Control: public, max-age=300 (5-minute TTL). The deploy renamed the field from line_items to lineItems in both the API response and the JavaScript consumer. The new JS loaded instantly (content hash changed), but the CDN continued serving the old response body for up to 5 minutes. The old API response had line_items, the new JS expected lineItems — mismatch, crash.
Fix
1) Deploy API changes in a backwards-compatible manner: serve both line_items and lineItems during rollout. 2) Reduce CDN TTL on API endpoints to max-age=30. 3) Wire CDN cache purge into the CI/CD pipeline so that on deploy, the relevant cache paths are invalidated before traffic shifts.
Key lesson
  • Never assume cache consistency across asset types — JS and API responses may have independent cache lives.
  • Always make API schema changes additive during a transition window.
  • CDN cache invalidation is a deployment step, not a post-deploy afterthought.
Production debug guideFollow these steps to determine if caching is the culprit and how to fix it.4 entries
Symptom · 01
User reports 'still seeing the old bug' hours after hotfix deploy. Server logs confirm new code is live.
Fix
Ask the user to open DevTools Network tab and check the Size column for the affected resource. '(disk cache)' means browser didn't ask the server. '304' means server confirmed cache is fresh — but check if the server actually sent the right resource.
Symptom · 02
Console shows a TypeError about a missing property (e.g., 'undefined is not an object') on a page that just deployed.
Fix
Check the Response Headers of the API call that returns data for that page. Look at Cache-Control and the actual response body. If you see old field names, the CDN or browser cache is serving stale data.
Symptom · 03
Hard refresh (Ctrl+Shift+R) fixes the issue, but normal page load doesn't.
Fix
This is a classic browser cache issue. Check the Cache-Control header on the HTML page. If max-age is > 0, that's your problem. HTML should always be no-cache.
Symptom · 04
Users behind a corporate proxy see old content even after you busted the cache with query strings.
Fix
Corporate proxies often ignore query strings for caching. Use content-hash filenames instead. Also check if your CDN respects query strings — some treat them as cache keys, some don't.
★ Cache Debug Cheat SheetWhen users report stale content, run these commands and checks in order. No theory — just the steps.
User sees old JS/CSS after deploy
Immediate action
Open DevTools > Network tab, hard refresh (Ctrl+Shift+R) to bypass cache. If fixed, it's a cache issue.
Commands
curl -I https://yoursite.com/static/js/app.*.bundle.js | grep -i cache-control
curl -I https://yoursite.com/ | grep -i cache-control
Fix now
Serve HTML with Cache-Control: no-cache. For JS/CSS, use content-hashed filenames with immutable.
API response is stale+
Immediate action
Check the response body in DevTools Network tab for the API endpoint. Compare to expected new schema.
Commands
curl -H 'Cache-Control: no-cache' https://api.yoursite.com/cart | head -c 500
Check CDN purge API (e.g., AWS CloudFront create-invalidation) to see if cache was purged during deploy.
Fix now
Add CDN cache purge step to CI/CD pipeline. Reduce CDN TTL to 30 seconds for API endpoints.
Service worker not updating+
Immediate action
Open DevTools > Application > Service Workers. Check if the new SW is waiting or active.
Commands
Check the response headers for sw.js: curl -I https://yoursite.com/sw.js | grep -i cache-control
Look for Cache-Control: no-store on sw.js in your server config.
Fix now
Add a server rule to serve sw.js with Cache-Control: no-store, no-cache, must-revalidate. Then unregister the old SW manually for testing.
Cache-Control Directive Reference
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

1
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.
2
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.
3
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.
4
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.
5
CDN caches are shared and need explicit purging on deploy. Never use Vary
Cookie on user-specific endpoints — use Cache-Control: private to keep data out of shared caches entirely.

Common mistakes to avoid

5 patterns
×

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. New JS expects renamed fields, CDN still serves old API response.
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.
×

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.
×

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.
×

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. no-cache still writes to disk, no-store never does.
Fix
Use Cache-Control: private, no-store for anything sensitive. no-cache is for revalidation, not for preventing persistence.
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
A user reports they're still seeing a bug you deployed a fix for 3 hours...
Q02SENIOR
Your team wants to cache API responses on the CDN for performance, but t...
Q03SENIOR
You're doing a zero-downtime deploy where both the old and new version o...
Q04SENIOR
What's the difference between Cache-Control: no-cache and Cache-Control:...
Q01 of 04SENIOR

A 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.

ANSWER
First, ask the user to open DevTools Network tab and check the Size column for the affected resource. If it says '(disk cache)' or '(memory cache)', the browser isn't even asking the server. That's a browser cache issue. Hard refresh (Ctrl+Shift+R) bypasses the browser cache. If that fixes it, the browser cache is the culprit. Check Cache-Control headers on the resource: if max-age is long and the filename isn't hashed, that's the problem. Fix: no-cache on HTML, content-hash on assets. If hard refresh doesn't fix it, the server or CDN is serving stale content. Check the response headers in the Network tab — if the server returns a 304 but the body is old, the server's ETag logic is wrong. If the server returns 200 with new content but the CDN still serves old, the CDN cache hasn't been purged. Check your CDN's cache invalidation status. Fix: wire cache purge into deploy pipeline. If none of these, it's not a cache issue — check proxy servers, DNS, or application state.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Why does clearing my browser cache fix so many random website problems?
02
What's the difference between browser cache and cookies?
03
How do I force all users to reload a JavaScript file I just updated in production?
04
Can a CDN serve a different cached response to different users for the same URL, and what header controls this?
🔥

That's Components. Mark it forged?

7 min read · try the examples if you haven't

Previous
HTTP 500 Internal Server Error: Causes, Debugging and Fixes
17 / 18 · Components
Next
gRPC vs REST: When to Use Each in Modern APIs