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 — SystemDesign tutorial
// Scenario: User visits a checkout page. Browser requests app.js.
// This diagram shows the decision flow the browser runs internally.
┌─────────────────────────────────────────────────────────────────┐
│ BROWSERCACHEDECISIONFLOW │
└─────────────────────────────────────────────────────────────────┘
User navigates to: https://shop.example.com/checkout
Browser needs: /static/js/app.v3.bundle.js
┌─────────────────┐
│ Check on-disk │
│ cache store │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │
CACHEMISSCACHEHIT
(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 │ FRESHSTALE
│ 200OK │ (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) │ │
└──────────────────────┘ ┌──────────────────┴─────────────────┐
│ │
304NOTMODIFIED200OK
(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. │ └─────────────────────┘
└───────────────────┘
// KEYINSIGHT: 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 — SystemDesign 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.1200OKContent-Type: text/html; charset=utf-8Cache-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 304if unchanged.
// ForHTML you ALWAYS want this — HTML references your other assets
// and must stay fresh so users pick up new deploys.
ETag: "d4a8f2e3"
// ↑ Content fingerprint. IfHTML 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.1200OKContent-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
// ↑ public: CDNs and shared caches can store this (fine forstatic assets)
// ↑ max-age=31536000: cache for exactly 1year (365 days × 24h × 60m × 60s)
// ↑ immutable: browser will NEVER revalidate thisURL — 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.1200OKContent-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.1200OKContent-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 ATALL — 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.1200OKContent-Type: image/webp
Cache-Control: public, max-age=86400Last-Modified: Tue, 08Jul202509:00:00GMTETag: "img-hero-v2"
// ↑ max-age=86400: cache for24hours (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
════════════════════════════════════════════════════════════
THEGOLDENRULEOFCACHEHEADERSTRATEGY
════════════════════════════════════════════════════════════
URLs that CHANGE content but keep the same path:
→ UseCache-Control: no-cache + ETag
→ Revalidation ensures freshness. Fast 304s keep it cheap.
URLs that NEVER change content (content-hashed filenames):
→ UseCache-Control: public, max-age=31536000, immutable
→ Cache forever. When content changes, URL changes.
User-specific or sensitive data:
→ UseCache-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 — SystemDesign tutorial
// Scenario: SaaS dashboard. Frontend team deploys a critical bugfix.
// Comparing3 cache-busting approaches by effectiveness and production safety.
════════════════════════════════════════════════════════════
APPROACH1: QueryStringVersioning (fragile, not recommended)
════════════════════════════════════════════════════════════
Deploy1: <script src="/static/js/dashboard.js?v=1"></script>
Deploy2: <script src="/static/js/dashboard.js?v=2"></script>
Problems:
✗ SomeCDNs 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.
SomeoneWILL 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=2URL.
════════════════════════════════════════════════════════════
APPROACH2: Content-HashFilename (production standard)
════════════════════════════════════════════════════════════
Deploy1 — 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
Deploy2 — one JS file changed:
/static/js/dashboard.b9e1f2a3.bundle.js ← newhash (content changed)
/static/css/styles.c2d8e4f0.css ← SAMEhash (content unchanged)
/index.html → references new dashboard.b9e1f2a3.bundle.js
What happens on user's next visit:
Step1: Browser fetches /index.html
→ SendsIf-None-Match with old ETag
→ Server: content changed (new script reference) → 200OK
→ Browser gets updated HTML pointing to newJS filename
Step2: Browser sees /static/js/dashboard.b9e1f2a3.bundle.js
→ CacheMISS (thisURL has never been fetched before)
→ Full download: 200OK
→ Cached with max-age=31536000, immutable
Step3: Browser sees /static/css/styles.c2d8e4f0.css
→ CacheHIT — same URL, still within max-age, immutable
→ Served from disk in 2ms, 0 bytes transferred
Result: User gets newJS instantly on next page load.
CSS served from cache (no wasted bandwidth).
Zero manual intervention from the dev team.
How your bundler does thisautomatically (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.
════════════════════════════════════════════════════════════
APPROACH3: Cache-Control: no-store on everything (nuclear option)
════════════════════════════════════════════════════════════
Cache-Control: no-store on all assets
Whatthis 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.
✗ CoreWebVitals tank. LCP and FCP suffer measurably.
UsethisONLYfor: sensitive user-specific API responses,
auth tokens, or data you genuinely must never persist to disk.
Never use it as a caching strategy forstatic assets.
════════════════════════════════════════════════════════════
DECISIONMATRIX
════════════════════════════════════════════════════════════
Asset type | Strategy
──────────────────────────────┼─────────────────────────────────────
HTML pages | Cache-Control: no-cache + ETagJS/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 + ETagAPIresponses (public data) | Cache-Control: public, max-age=60 (shortTTL)
APIresponses (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 — SystemDesign tutorial
// Scenario: Diagnosing and fixing 3 real cache-related production failures.
// Each block shows: what went wrong → why → exact fix.
════════════════════════════════════════════════════════════
FAILURE1: Split-brain deploy — newJS 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)
NewJS expects: { lineItems: [...] } ← renamed field in newAPICDN still serving: { line_items: [...] } ← old field name
New frontend code + old API response = runtime crash
Fix:
Option A — Never rename fields, only ADDnewones (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 — SystemDesign tutorial
// Scenario: Global e-commerce site with CDN. How to handle Vary and cache invalidation.
════════════════════════════════════════════════════════════
VARYHEADEREXAMPLES
════════════════════════════════════════════════════════════
// Good: vary on encoding (standard for compressed responses)
Cache-Control: public, max-age=31536000Vary: 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=3600Vary: 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 Authorizationfor user-specific data
Cache-Control: publicVary: 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.
// Betterfor user-specific APIs:
Cache-Control: private, no-store
// NoVary needed — CDN is prohibited from caching altogether.
════════════════════════════════════════════════════════════
CI/CDPIPELINE: CDNCACHEPURGEONDEPLOY
════════════════════════════════════════════════════════════
# Example using AWSCloudFront 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/*"
# ForFastly:
# curl -X POST -H "Fastly-Key: $API_KEY" \n # https://api.fastly.com/service/$SERVICE_ID/purge?path=/static
# ForCloudflare:
# 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/*"]}'
// Runthis invocation AFTER your deploy completes but BEFORE you
// declare the deploy successful in your monitoring.
════════════════════════════════════════════════════════════
CDNCACHEDECISIONFLOW
════════════════════════════════════════════════════════════
User request reaches CDN edge node:
┌─────────────────────────────────────────────────────────────┐
│ Does the CDN have a cached response forthisURL? │
│ (matching cache key = URL + Vary headers) │
└──────────┬──────────────────────────────────────────────────┘
│
┌─────┴─────┐
│ │
HITMISS
│ │
▼ ▼
┌──────────┐ ┌──────────────────┐
│ Serve │ │ Forward to │
│ cached │ │ origin server │
│ response │ └────────┬─────────┘
└──────────┘ │
▼
┌──────────────────────┐
│ Origin responds: │
│ 200OK + 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.
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 Directive
Browser Caches to Disk?
Network Request on Each Visit?
Safe for User Data?
Best Used For
max-age=31536000, immutable
Yes
No (until URL changes)
Only for public assets
Content-hashed JS, CSS, fonts, images
no-cache
Yes
Yes — but 304 if unchanged
Yes (file stays fresh)
HTML pages, non-hashed assets
no-store
No
Yes — full fetch every time
Yes — nothing persisted
Auth tokens, user-specific API responses
private, max-age=300
Yes (browser only)
No (within 5 min window)
Only if truly private
User-specific but non-sensitive API data
public, max-age=60
Yes (browser + CDN)
No (within 60 sec window)
No — avoid for user data
Public API endpoints with acceptable staleness
must-revalidate
Yes
Yes — after max-age expires
Yes
Paired with max-age to enforce strict expiry
stale-while-revalidate=30
Yes
Serves stale, fetches in background
Depends on content
Non-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.
Q02 of 04SENIOR
Your 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?
ANSWER
The header that controls whether a CDN can cache is Cache-Control: public vs private. If any part of the response is user-specific, you should split the endpoint into two: one for public data (cachable) and one for user-specific data (private, no-store). Alternatively, if you can separate the data sources on the front-end, the public part can be fetched from a different endpoint with public caching. You should never use Cache-Control: public on responses that contain user-specific data — even if you use Vary headers, the risk of accidentally serving one user's data to another is too high. The safer approach: keep user-specific data in a private, no-store API endpoint and fetch public data (like product catalog) from a separate public endpoint with a short TTL (e.g., max-age=60).
Q03 of 04SENIOR
You'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?
ANSWER
During the rollover window, users could be routed to either the old or new version of the front-end, and the API could also be either old or new. The risk is: a user on the new front-end hits the old API (which still serves the old field name), causing a runtime TypeError. Or a user on the old front-end hits the new API and misses the new field. The window of inconsistency is 90 seconds plus any CDN cache TTL. The permanent fix: never rename fields in a single deploy. Instead, add the new field alongside the old one. The old front-end uses the old field, the new front-end uses the new field. After the rollover completes and all caches have expired, remove the old field in a future deploy. This is backwards-compatible deployment — it's the only safe way to change shared schemas across independently deployed components.
Q04 of 04SENIOR
What'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.
ANSWER
no-cache caches the response to disk but requires revalidation with the server before serving it on each use. no-store prohibits the browser from storing the response anywhere — not in disk cache, not in memory cache. Using no-cache on sensitive data like session tokens or user PII means the data gets written to disk in an area the browser manages, but any malicious code or local actor could potentially read it. A concrete scenario: a banking app uses no-cache on an endpoint that returns session tokens. An attacker with local access to the user's machine browses the browser's cache folder (e.g., Chrome's /Cache directory) and finds the plaintext session token. With that token, they can impersonate the user. The fix is to use no-store, which ensures the token never touches disk.
01
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.
SENIOR
02
Your 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?
SENIOR
03
You'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?
SENIOR
04
What'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.
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.