The Browser Contract
HTTP Caching: Cache-Control, ETags and the CDN in Between
Fresh or must-check, no-cache vs no-store, immutable for hashed files, stale-while-revalidate, and what a shared cache must never store.
On this page
- The two mechanisms
- The Cache-Control directives you must know
- What happens when there is no Cache-Control?
- The standard recipe for a modern web app
- The Vary header
- How reload and navigation behave
- Scenario: users see old data after a deploy
- What a shared CDN must never cache
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: HTTP caching lets the browser reuse a response instead of fetching it again. Cache-Control says how long the response is fresh and who may store it. Validators (ETag) let the browser ask "has it changed?" in a cheap way. Do it right and pages load instantly. Do it wrong and users see yesterday's site.
A cache is a place that keeps a copy of a response so the next request can reuse it. Your browser has one. Servers called CDNs and proxies (explained below) have them too. Caching is where speed and correctness meet. This lesson explains the directives (the instructions in the header), the two mechanisms (freshness and validation), a standard recipe for a modern web app, and a way to debug "users see old data after a deploy".
The two mechanisms
1. Freshness: "use it without asking"
A stored response is fresh while its allowed lifetime has not run out. A fresh response is served at once. There is no network request.
Cache-Control: max-age=3600This means "fresh for one hour". During that hour, the browser reuses the response straight from its cache.
2. Validation: "ask whether it changed"
A response becomes stale (too old to reuse without checking) when its fresh time is over. (Some responses must always be checked.) Then the browser sends a conditional request: a normal request plus a validator, which is a value that identifies the version the browser already has. The server answers either "still good" or with the new body.
# First responseHTTP/2 200ETag: "a1b2c3"Cache-Control: no-cache# Later requestGET /data.jsonIf-None-Match: "a1b2c3"# Server: nothing changed — no body sentHTTP/2 304 Not ModifiedA 304 (the status code "Not Modified") saves the download (bandwidth, the amount of data sent). It still costs a round trip: one trip to the server and back, which takes waiting time. There are two validators:
| Validator | Sent back as | Notes |
|---|---|---|
ETag (a fingerprint of the content) | If-None-Match | Exact. A weak ETag (W/"...") means "the same in meaning" |
Last-Modified (a date) | If-Modified-Since | Rough (it counts in whole seconds) |
The Cache-Control directives you must know
Cache-Control is an HTTP header (a line of "name: value" information sent with a response). A directive is one instruction inside it, like max-age=3600. You can combine several, separated by commas.
| Directive | Meaning |
|---|---|
max-age=N | Fresh for N seconds (in the browser and in shared caches) |
s-maxage=N | Like max-age, but only for shared caches (CDNs, proxies). It overrides max-age there |
public | Any cache may store it, even if it normally could not (for example, when Authorization is present) |
private | Only the user's own browser may store it, not a shared cache. Use it for personal responses |
no-cache | Store it, but check with the server before every reuse. It does not mean "don't cache" |
no-store | Do not store it anywhere. Use it for sensitive data |
must-revalidate | Once the response is stale, do not use it without checking first (no "use the old copy if there is an error" shortcuts) |
immutable | The content will never change at this URL. Skip the check, even on reload |
stale-while-revalidate=N | After the response goes stale, use the old copy for up to N seconds while a new copy loads in the background |
stale-if-error=N | If the origin server has an error, use the old copy for up to N seconds |
no-cache and no-store are the pair people mix up most. no-cache = keep a copy, but check with the server before using it (a cheap 304 is possible). no-store = never keep a copy. Sensitive pages (account details, banking, anything with a session) should use no-store. OWASP asks for this so that the Back button and shared computers do not show them.
Where a response is stored
- Browser (private) cache: one for each user.
- Shared caches: a CDN edge, a reverse proxy, a company proxy. A CDN (content delivery network) is a group of servers around the world that keep copies of your files; an edge is one of those servers, close to the user. A proxy is a server that passes requests on for someone else. Many users share one stored copy. This is why personal content must be
privateorno-store.
What happens when there is no Cache-Control?
Browsers use heuristic freshness, which means they guess how long a response is fresh. If a response has Last-Modified but no set lifetime, a browser may treat it as fresh for a fraction of the time since that date (the HTTP specification suggests 10%). For example, a file last changed 10 days ago may be reused for about 1 day without any check. This is how a server with no settings causes confusing "sometimes old" behaviour. Always send an explicit Cache-Control.
The standard recipe for a modern web app
| Resource | Header | Why |
|---|---|---|
Hashed build files (app.8f3a1c.js, style.4b2d.css, /_next/static/…) | Cache-Control: public, max-age=31536000, immutable | The file name changes whenever the content changes. So it is safe to cache for a year |
| HTML documents | Cache-Control: no-cache (or max-age=0, must-revalidate) | Always check, so users get the new HTML that points to the new file names. A 304 keeps it cheap |
| Images with stable URLs | max-age=86400 + stale-while-revalidate=604800 | Fast, and refreshed quietly |
| API GET responses, public data | public, s-maxage=60, stale-while-revalidate=300 | The CDN takes the traffic. Clients still see new data within about a minute |
| API responses, for one user | private, no-cache (or no-store if sensitive) | Never let a shared cache store someone's data |
| Responses that set cookies or carry secrets | no-store | Must not be reused |
Cache busting means changing the URL when the content changes, so old cached copies are simply never asked for again. A content hash is a short fingerprint calculated from the file's bytes, and it goes in the file name. You do not try to make old caches expire. Build tools such as Webpack, Vite and Next.js do this for you. A version in the query string (app.js?v=3) works in browsers. But some proxies refuse to cache URLs with a query string. So a hash in the file name is more reliable.
// Next.js: custom headers in next.config.tsexport default { async headers() { return [ { source: "/_next/static/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }] }, { source: "/api/me", headers: [{ key: "Cache-Control", value: "private, no-store" }] }, ]; },};# nginx (a popular web server and proxy)location ~* \.(js|css|woff2|png|jpg|webp|svg)$ { add_header Cache-Control "public, max-age=31536000, immutable";}location / { add_header Cache-Control "no-cache";}The Vary header
If the response depends on a request header, say so. Vary is a response header that lists the request headers the answer depends on. Vary: Accept-Encoding tells caches to store the gzip and Brotli versions separately. (These are two ways to compress a response so it is smaller to download.) Vary: Origin is needed when the CORS headers are different for each origin (Lesson 04). Without Vary, a cache can serve the wrong version. Vary: * means "never reuse this from a cache", and Vary: Cookie makes a shared cache keep one copy per cookie value, which rarely helps. Both can be what you want for personal pages.
How reload and navigation behave
| Action | What the browser does |
|---|---|
| Normal navigation / clicking a link | Uses the cache as usual (fresh = no request) |
| Reload (F5) | Checks the main document again with the server. Other files, such as images and scripts, may still come from the cache if they are fresh |
| Hard reload (Ctrl+Shift+R) | Skips the cache for everything |
| Back / Forward | Prefers cached copies, even old ones (and the bfcache, the "back/forward cache", may bring back the whole page exactly as you left it) |
| DevTools "Disable cache" | Works only while DevTools is open. Remember that users do not have it |
Scenario: users see old data after a deploy
Debug it as a chain of caches. Start at the browser and move outward:
- Is the HTML cached with a long
max-age? Then browsers keep the old HTML, which points to old bundle file names. Fix: HTML must beno-cache. - Is the CDN caching the HTML? Purge it (tell the CDN to delete its stored copy) on deploy. A deploy is publishing a new version of your site. Or use a short
s-maxageplusstale-while-revalidate, and make your pipeline clear the changed paths. - Are the file names hashed? If you ship
app.jswith a longmax-age, nothing will update it. Use hashed names. - Do the old chunks still exist? A chunk is one of the smaller JavaScript files that a build tool splits your app into. Users on the old HTML will ask for old chunk files. If your deploy deletes them, they get 404 errors (a "ChunkLoadError"). Keep the previous build's files for a while.
- Is a Service Worker serving an old page shell? A Service Worker is a script that runs in the browser between your page and the network and can answer requests from its own stored files. It can keep serving an old version until a new worker takes over. Make sure it updates, and that it calls
skipWaiting(a method that lets the new worker take over at once) or asks the user to refresh on purpose. - Check
Ageand the cache-status headers (x-cache,cf-cache-status) in the response. They show whether a CDN served a stored copy.Ageis the number of seconds the copy has been stored. - Is the API response cached too long? Check
Cache-Controlon the data endpoints.
What a shared CDN must never cache
- Responses with
Set-Cookieor personal data, unless the cache is keyed on the user (usually do not do this). - Anything tied to an
Authorizationheader (do not addpublic). - Anything marked
privateorno-store. CDNs should respect this, but check your CDN settings, because rules sometimes override headers. - Error pages that would be shown to everyone for minutes (a
5xxcached by mistake).
Common mistakes
- A long
max-ageon HTML (users stay stuck on an old version). - Mixing up
no-cacheandno-store. - Caching personal API responses at the CDN. One user sees another user's data. This is a serious incident.
- Not hashing file names. Then you keep fighting "please clear your cache" support requests.
- Relying on heuristic caching because no header was set.
- Deleting old files right away on deploy.
- Forgetting
Vary. - Thinking
304is free. It still costs a round trip. For static files, preferimmutableand a longmax-age.
Interview questions
What's the difference between no-cache and no-store?
no-cache allows the response to be stored. But the cache must check with the server before each reuse (often this gives a cheap 304). no-store forbids storing it at all. Use no-store for sensitive data. Use no-cache for HTML that you want fresh but still cheap to check.
How do ETag and If-None-Match work, and what is a 304?
The server sends an ETag (a fingerprint) with the response. On the next request, the browser sends If-None-Match with that value. If the content has not changed, the server returns 304 Not Modified with no body. The browser then reuses its stored copy. This saves bandwidth, but it still needs a round trip.
How do you cache-bust a JS bundle safely?
Put a content hash in the file name (main.8f3a1c.js). Serve it with Cache-Control: public, max-age=31536000, immutable. When the content changes, the name changes, so the browser fetches the new file. The HTML that points to the file must be checked each time (no-cache), so users learn the new name.
Scenario: users see stale data after a deploy. Diagnose it.
Go outward step by step. Possible causes: HTML with a max-age that is too long, a CDN serving cached HTML (check Age and x-cache), file names with no hash, deleted old chunks, a Service Worker keeping the old shell, or cached API responses. The fixes: make HTML no-cache, hash the file names, purge the CDN on deploy, keep old files for a short time, and set clear cache headers on the API.
What should a shared CDN never cache?
It should never cache personal or logged-in responses (Set-Cookie, Authorization, user data). It should never cache anything marked private or no-store. It should never cache short-lived error responses. A mistake here can show one user's data to another user.
What is stale-while-revalidate and when is it useful?
It lets a cache serve an old response at once, while it fetches a fresh one in the background. Users get instant responses, and the data is refreshed for the next request. It is great for dashboards, product lists and public API data, where a few minutes of old data is fine.
What is the difference between max-age and s-maxage?
max-age applies to all caches, including the browser. s-maxage applies only to shared caches and overrides max-age there. With it, you can cache at the CDN for a longer or shorter time than in browsers. For example: max-age=0, s-maxage=300.
Why does Vary matter?
It tells caches which request headers change the response (such as Accept-Encoding or Origin). Without it, a cache could send a gzip response to a client that cannot decode it. Or it could send one origin's CORS headers to another origin.
Scenario: a logged-in user saw another user's name on the home page after a CDN change. What happened and how do you fix it?
A shared cache stored a personal response and served it to someone else. First, purge the CDN at once. Then fix the cause. Personal HTML or API responses must send Cache-Control: private, no-store (or the CDN must key the cache on the session). The cache key must not ignore cookies. Vary: Cookie must not be removed. Add a test that checks that two different users get different responses. Also review the CDN rules for "cache everything" patterns.
Key takeaways
- Freshness (
max-age) skips the network. Validation (ETag,304) saves the download. no-cache= check first.no-store= never keep.- The recipe: hashed files get
immutablefor a year. HTML getsno-cache. Personal data getsprivateorno-store. Public API data getss-maxage+stale-while-revalidate. - Cache-bust by changing the URL. Do not hope that caches will expire.
- Debug old content by walking the cache chain: browser, Service Worker, CDN, origin.
What's next
Lesson 06: HTTP Semantics Interviewers Actually Ask. Caching depends on methods and status codes. Next we make the vocabulary clear: safe and idempotent methods, 401 vs 403, and the 301/302/307/308 table.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Design a CDN · System Design Case Studies 2
- Cost, Latency and Caching for LLM Features · AI Engineering
- HTTP Methods, Status Codes and Idempotency in Practice · API Design
Related series