Browser Cache Explained: How It Works and When It Breaks Your App
- Content-hash your filenames and serve them with max-age=31536000, immutable β this is the only cache strategy that gives you both infinite performance and instant cache busting on deploy, because the URL itself changes when content changes.
- HTML pages must always be served with Cache-Control: no-cache. It's the index of your app. If users cache stale HTML, they load stale everything β even your correctly-busted hashed assets won't help because the HTML still points to old filenames.
- no-cache and no-store are not synonyms. no-cache caches to disk but revalidates on every use. no-store never touches disk. Confusing these two in production has caused real security incidents where session tokens ended up persisted in browser cache folders.
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.
// io.thecodeforge β System Design tutorial // Scenario: User visits a checkout page. Browser requests app.js. // This diagram shows the decision flow the browser runs internally. βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β BROWSER CACHE DECISION FLOW β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ User navigates to: https://shop.example.com/checkout Browser needs: /static/js/app.v3.bundle.js βββββββββββββββββββ β Check on-disk β β cache store β ββββββββββ¬βββββββββ β βββββββββββββββββββββΌββββββββββββββββββββ β β CACHE MISS CACHE HIT (file not found locally) (file found in cache folder) β β βΌ βΌ ββββββββββββββββββββββββ ββββββββββββββββββββββββββββ β Fetch from network β β Is the cached copy β β GET /static/js/ β β still within its β β app.v3.bundle.js β β freshness window? β ββββββββββββ¬ββββββββββββ ββββββββββββββ¬ββββββββββββββ β β βΌ ββββββββββββ΄βββββββββββ ββββββββββββββββββββββββ β β β Server responds β FRESH STALE β 200 OK β (max-age not expired) (max-age expired) β + Cache-Control β β β β headers β βΌ βΌ ββββββββββββ¬ββββββββββββ ββββββββββββββββββ βββββββββββββββββββββββ β β Serve from β β Send conditional β βΌ β disk. No β β request to server: β ββββββββββββββββββββββββ β network call. β β If-None-Match or β β Write to cache β β Instant. β β If-Modified-Since β β store with metadata β ββββββββββββββββββ ββββββββββββ¬βββββββββββ β (URL, expiry, ETag) β β ββββββββββββββββββββββββ ββββββββββββββββββββ΄ββββββββββββββββββ β β 304 NOT MODIFIED 200 OK (server confirms copy (server sends new file) is still current) β β βΌ βΌ βββββββββββββββββββββββ βββββββββββββββββββββ β Update cache with β β Use cached copy. β β new file + new β β Only metadata β β expiry metadata β β updated on disk. β βββββββββββββββββββββββ βββββββββββββββββββββ // KEY INSIGHT: A 304 response body is empty β the server only sends headers. // This is dramatically cheaper than a full 200 response with file payload. // On a 500KB JS bundle, that's the difference between 500KB and ~200 bytes.
β 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
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.
// io.thecodeforge β System Design tutorial // Scenario: E-commerce platform serving an HTML page + hashed static assets. // This shows the header strategy used in production for each asset type. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REQUEST: GET /checkout (HTML page) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Cache-Control: no-cache // β 'no-cache' does NOT mean 'don't cache'. It means: // 'cache it, but revalidate with the server on every visit.' // Browser will send If-None-Match header. Server sends 304 if unchanged. // For HTML you ALWAYS want this β HTML references your other assets // and must stay fresh so users pick up new deploys. ETag: "d4a8f2e3" // β Content fingerprint. If HTML changes, ETag changes. // Browser sends this back as: If-None-Match: "d4a8f2e3" // Server compares, responds 304 or 200. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REQUEST: GET /static/js/app.a7f3c9b1.bundle.js (hashed JS) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HTTP/1.1 200 OK Content-Type: application/javascript Cache-Control: public, max-age=31536000, immutable // β public: CDNs and shared caches can store this (fine for static assets) // β max-age=31536000: cache for exactly 1 year (365 days Γ 24h Γ 60m Γ 60s) // β immutable: browser will NEVER revalidate this URL β not even when expired // This is safe ONLY because the filename contains a content hash (a7f3c9b1). // When code changes, the bundler generates a NEW filename with a NEW hash. // The old URL is simply never requested again. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REQUEST: GET /static/css/styles.c2d8e4f0.css (hashed CSS) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HTTP/1.1 200 OK Content-Type: text/css Cache-Control: public, max-age=31536000, immutable // Same strategy as JS. Hashed filename = safe to cache forever. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REQUEST: GET /api/cart/items (API response) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, no-store // β private: never let a CDN or proxy cache user-specific data // β no-store: don't write this to disk AT ALL β cart data is user-specific // and changes constantly. Caching it causes users to see // each other's data or stale totals. This has burned people. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ REQUEST: GET /images/hero-banner.webp (marketing image, no hash) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HTTP/1.1 200 OK Content-Type: image/webp Cache-Control: public, max-age=86400 Last-Modified: Tue, 08 Jul 2025 09:00:00 GMT ETag: "img-hero-v2" // β max-age=86400: cache for 24 hours (no content hash in filename, // so we can't use immutable β the URL might serve new content) // β After 24h, browser sends: If-Modified-Since + If-None-Match // β Server compares timestamps/ETags and responds 200 or 304 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ THE GOLDEN RULE OF CACHE HEADER STRATEGY ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ URLs that CHANGE content but keep the same path: β Use Cache-Control: no-cache + ETag β Revalidation ensures freshness. Fast 304s keep it cheap. URLs that NEVER change content (content-hashed filenames): β Use Cache-Control: public, max-age=31536000, immutable β Cache forever. When content changes, URL changes. User-specific or sensitive data: β Use Cache-Control: private, no-store β Never cache. Never share between users.
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.
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.
// io.thecodeforge β System Design tutorial // Scenario: SaaS dashboard. Frontend team deploys a critical bugfix. // Comparing 3 cache-busting approaches by effectiveness and production safety. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ APPROACH 1: Query String Versioning (fragile, not recommended) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Deploy 1: <script src="/static/js/dashboard.js?v=1"></script> Deploy 2: <script src="/static/js/dashboard.js?v=2"></script> Problems: β Some CDNs and shared caches IGNORE query strings and cache the base URL only. Users behind a corporate proxy may never see version 2 even though you updated the query param. β You must manually increment the version number. Someone WILL forget. It has happened on every team I've seen use this. β The file itself hasn't changed name β old CDN nodes may serve cached v=1 content for the v=2 URL. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ APPROACH 2: Content-Hash Filename (production standard) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Deploy 1 β source unchanged: /static/js/dashboard.a7f3c9b1.bundle.js /static/css/styles.c2d8e4f0.css /index.html β references these exact filenames Cache-Control on .js/.css: public, max-age=31536000, immutable Cache-Control on index.html: no-cache Deploy 2 β one JS file changed: /static/js/dashboard.b9e1f2a3.bundle.js β new hash (content changed) /static/css/styles.c2d8e4f0.css β SAME hash (content unchanged) /index.html β references new dashboard.b9e1f2a3.bundle.js What happens on user's next visit: Step 1: Browser fetches /index.html β Sends If-None-Match with old ETag β Server: content changed (new script reference) β 200 OK β Browser gets updated HTML pointing to new JS filename Step 2: Browser sees /static/js/dashboard.b9e1f2a3.bundle.js β Cache MISS (this URL has never been fetched before) β Full download: 200 OK β Cached with max-age=31536000, immutable Step 3: Browser sees /static/css/styles.c2d8e4f0.css β Cache HIT β same URL, still within max-age, immutable β Served from disk in 2ms, 0 bytes transferred Result: User gets new JS instantly on next page load. CSS served from cache (no wasted bandwidth). Zero manual intervention from the dev team. How your bundler does this automatically (Vite config example): build: { rollupOptions: { output: { entryFileNames: 'assets/[name].[hash].js', chunkFileNames: 'assets/[name].[hash].js', assetFileNames: 'assets/[name].[hash][extname]', } } } // [hash] is computed from file contents by Rollup/Vite automatically. // Same source = same hash every build. Deterministic. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ APPROACH 3: Cache-Control: no-store on everything (nuclear option) ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Cache-Control: no-store on all assets What this does: forces a full re-download of every resource on every page load. Problems: β Users on slow connections re-download your entire 2MB app bundle every single page navigation. This tanks performance metrics. β Your origin servers absorb 100% of traffic. No cache offloading. At scale this costs real money and causes load spikes on deploys. β Core Web Vitals tank. LCP and FCP suffer measurably. Use this ONLY for: sensitive user-specific API responses, auth tokens, or data you genuinely must never persist to disk. Never use it as a caching strategy for static assets. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ DECISION MATRIX ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Asset type | Strategy βββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ HTML pages | Cache-Control: no-cache + ETag JS/CSS (content-hashed name) | Cache-Control: public, max-age=31536000, immutable Images (with hash in name) | Cache-Control: public, max-age=31536000, immutable Images (without hash) | Cache-Control: public, max-age=86400 + ETag API responses (public data) | Cache-Control: public, max-age=60 (short TTL) API responses (user-specific) | Cache-Control: private, no-store Auth tokens / session data | Cache-Control: private, no-store
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
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.
// io.thecodeforge β System Design tutorial // Scenario: Diagnosing and fixing 3 real cache-related production failures. // Each block shows: what went wrong β why β exact fix. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ FAILURE 1: Split-brain deploy β new JS hits old API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Symptom: TypeError: Cannot read properties of undefined (reading 'map') console shows: cartData.lineItems is undefined Affects: ~30% of users for exactly 5 minutes after every deploy Passes in: all test environments (tests don't use CDN cache) Root cause: API /api/cart response cached by CDN with max-age=300 (5 minutes) New JS expects: { lineItems: [...] } β renamed field in new API CDN still serving: { line_items: [...] } β old field name New frontend code + old API response = runtime crash Fix: Option A β Never rename fields, only ADD new ones (backwards compatible) Old response: { line_items: [...], lineItems: [...] } β both fields New JS uses lineItems. Old JS uses line_items. Both work during rollout. Deprecate line_items in a future deploy after cache expires. Option B β Reduce CDN TTL on API responses that change with deploys Cache-Control: public, max-age=30 (30 seconds, not 5 minutes) Acceptable stale window drops from 5 minutes to 30 seconds. Option C β Use CDN cache invalidation API on deploy Most CDNs (CloudFront, Fastly, Cloudflare) expose an API to explicitly purge cached paths on deploy. Wire this into your CI/CD pipeline. Run it before traffic shifts to new containers. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ FAILURE 2: Broken HTML served from cache after botched deploy ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Symptom: Deploy rolled back. Server is serving correct HTML again. But ~15% of users still see broken page for hours. Root cause: index.html was served with Cache-Control: max-age=3600 (someone 'optimised' HTML caching without understanding the risk) Those users cached the broken HTML locally during the bad deploy window. Browser serves it from disk for up to 1 hour. Rollback invisible to them. Fix: HTML pages must NEVER have a long max-age. Use: Cache-Control: no-cache This forces a revalidation on every visit. The 304 Not Modified response is cheap β usually < 200 bytes. The cost of a 304 is vastly outweighed by the ability to recover from a bad deploy without users being stuck on broken HTML for an hour. ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ FAILURE 3: Service worker serves stale app indefinitely ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Symptom: Deployed critical security patch to auth flow. New service worker registers. Old app keeps loading for most users. Users who explicitly hit Ctrl+Shift+R see the new version. Normal visits: still old code, indefinitely. Root cause: Service worker script (sw.js) was cached by the HTTP cache with Cache-Control: max-age=86400 (24 hours). Browser checks for a new service worker on each visit β but it was getting the old sw.js from the HTTP cache, not the server. The browser thought: 'sw.js hasn't changed' (byte-for-byte comparison of cached file vs cached file). Old service worker stayed active. Fix: Service worker scripts must NEVER be cached by the HTTP cache. Cache-Control: no-store on sw.js specifically. The browser has its own 24-hour update check for service workers (separate from the HTTP cache) β let that mechanism work. Don't layer HTTP caching on top of it. Nginx config for this: location = /sw.js { add_header Cache-Control "no-store, no-cache, must-revalidate"; proxy_pass http://app_upstream; } # Every other static asset keeps long max-age + immutable ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ QUICK DIAGNOSTIC β when users report 'seeing old content' ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Step 1: Open Network tab in DevTools Filter by the affected resource (JS file, HTML, image) Check 'Size' column: '(disk cache)' β browser didn't even ask the server '(memory cache)' β in-memory, not hit network A number in KB β came from network Step 2: Check Response Headers of the cached resource Look for Cache-Control, Expires, ETag, Last-Modified How long is max-age? Has it expired? Step 3: Check if the filename has a content hash app.a7f3c9b1.bundle.js β hashed, should be immutable app.bundle.js β not hashed, relies on max-age/ETag Step 4: Hard refresh (Ctrl+Shift+R) to bypass cache If this fixes it: caching is the problem If this doesn't fix it: the issue is server-side
12:00:00 β Deploy starts. New containers come online.
12:00:15 β New JS bundle (lineItems) starts serving.
12:00:15 β CDN still has old API response cached ({line_items}).
12:00:15 to 12:05:15 β TypeError on checkout for users who got new JS.
12:05:16 β CDN cache expires. New API response propagates.
12:05:16 β Error rate drops to zero.
Impact: 5 minutes of checkout failures per deploy.
Failure 2 timeline:
13:00:00 β Bad deploy. Broken HTML cached by some browsers (max-age=3600).
13:02:00 β Rollback complete. Server healthy.
13:02:00 β Users with cached broken HTML: still broken.
14:00:00 β max-age expires. Those users finally get good HTML.
Impact: 58 minutes of broken experience post-rollback.
Failure 3:
Post-deploy: 12% of users on updated service worker.
88% of users: old service worker, HTTP cache serving stale sw.js.
Resolution: required manual cache-busting comms to affected users.
| 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
- Content-hash your filenames and serve them with max-age=31536000, immutable β this is the only cache strategy that gives you both infinite performance and instant cache busting on deploy, because the URL itself changes when content changes.
- HTML pages must always be served with Cache-Control: no-cache. It's the index of your app. If users cache stale HTML, they load stale everything β even your correctly-busted hashed assets won't help because the HTML still points to old filenames.
- no-cache and no-store are not synonyms. no-cache caches to disk but revalidates on every use. no-store never touches disk. Confusing these two in production has caused real security incidents where session tokens ended up persisted in browser cache folders.
- The browser cache is entirely controlled by HTTP response headers from your server β you have no direct access to what's in a user's cache once it's there. Your only lever is the URL. Change the URL and the cache is irrelevant. That's the entire theory behind content hashing.
β Common Mistakes to Avoid
- βMistake 1: Deploying new JS and API schema changes simultaneously without backwards compatibility β symptom: TypeError in browser console affecting a percentage of users for exactly the duration of your CDN TTL β fix: make API changes additive (add new fields alongside old ones), reduce CDN TTL on API endpoints to max-age=30, and wire CDN cache purge into your CI/CD deploy step.
- βMistake 2: Setting Cache-Control: max-age=3600 or longer on HTML pages β symptom: users report 'still seeing the old site' hours after deploy, Ctrl+Shift+R fixes it for them individually but you can't force this for all users β fix: always serve HTML with Cache-Control: no-cache and a strong ETag; the 304 revalidation costs microseconds but gives you instant recovery from bad deploys.
- βMistake 3: Caching the service worker script (sw.js) with a long max-age via HTTP headers β symptom: new service worker registers on your server but most users never receive it; bypass requires users to open DevTools > Application > Clear Storage β fix: add a specific Nginx or CDN rule serving sw.js with Cache-Control: no-store, keeping the browser's built-in 24-hour SW update check as the only caching layer.
- βMistake 4: Using Cache-Control: no-cache thinking it means 'never cache this' β symptom: sensitive data (session tokens, user PII) found in browser cache folder in plaintext during a security audit β fix: use Cache-Control: private, no-store for anything sensitive; no-cache still writes to disk, no-store never does.
- βMistake 5: Not setting the immutable directive on content-hashed assets β symptom: users doing a hard refresh (Ctrl+Shift+R) trigger full re-downloads of JS and CSS bundles that haven't changed, visible as unnecessary 200 responses on hashed filenames in the Network tab β fix: add immutable to Cache-Control on all assets whose filenames contain a content hash; it eliminates revalidation requests even on forced refreshes.
Interview Questions on This Topic
- QA user reports they're still seeing a bug you deployed a fix for 3 hours ago. Your server logs confirm the new code is live. Walk me through exactly how you'd diagnose whether this is a browser cache issue, a CDN cache issue, or something else β and what's your fix for each case.
- QYour team wants to cache API responses on the CDN for performance, but the API returns user-specific data mixed with public data in the same endpoint. How do you handle caching safely, and what header specifically controls whether a CDN is allowed to cache a response at all?
- QYou're doing a zero-downtime deploy where both the old and new version of your app run simultaneously for 90 seconds during rollover. Your JS bundle and your API share a renamed field. What breaks, for which users, for how long β and what's the architectural change that prevents this class of problem permanently?
- QWhat's the difference between Cache-Control: no-cache and Cache-Control: no-store, and give me a concrete production scenario where using the wrong one causes a security incident.
Frequently Asked Questions
Why does clearing my browser cache fix so many random website problems?
Because the browser was serving you a stale local copy of a file that had changed on the server β and since the cached copy hadn't expired yet, the browser never asked the server for an update. Clearing the cache deletes all those local copies, forcing the browser to fetch everything fresh from the network on the next visit. It's a blunt instrument. The proper fix is on the server side: correct Cache-Control headers and content-hashed filenames so users get updates automatically without ever needing to clear their cache manually.
What's the difference between browser cache and cookies?
Completely different mechanisms with different purposes. The browser cache stores copies of files β JavaScript, CSS, images, HTML β to avoid re-downloading them. Cookies store small pieces of text data that get sent back to the server with every request, typically used for session identifiers and user preferences. You can't store a JavaScript file in a cookie, and cookies don't speed up file loading. The rule: cache is for performance, cookies are for state.
How do I force all users to reload a JavaScript file I just updated in production?
The reliable way is to change the file's URL β specifically by adding a content hash to the filename (e.g. app.a7f3c9b1.bundle.js becomes app.b9e1f2a3.bundle.js after a change). Every modern bundler (Vite, Webpack, Parcel) does this automatically. Then serve your HTML with Cache-Control: no-cache so browsers always fetch the latest HTML pointing to the new filename. The old cached JS file becomes irrelevant β nothing references its URL anymore. Appending a query string (?v=2) is a weaker fallback that some CDNs ignore.
Can a CDN serve a different cached response to different users for the same URL, and what header controls this?
Yes, through the Vary header. Cache-Control: public tells CDNs they can cache a response, but Vary: Accept-Language tells the CDN to maintain separate cached copies for each language variant. So French users get a cached French response and English users get a cached English response from the same CDN node, for the same URL. The dangerous version is Vary: Cookie or Vary: Authorization β these tell CDNs to cache separately per user, which can explode your CDN cache storage and effectively kills the performance benefit. For user-specific data, skip the CDN entirely and use Cache-Control: private so only the browser caches it locally.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.