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.

Lesson 0520 min read#caching#http#cdn#performance

On this page

  1. The two mechanisms
  2. The Cache-Control directives you must know
  3. What happens when there is no Cache-Control?
  4. The standard recipe for a modern web app
  5. The Vary header
  6. How reload and navigation behave
  7. Scenario: users see old data after a deploy
  8. What a shared CDN must never cache
  9. Common mistakes
  10. Interview questions
  11. Key takeaways
  12. 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.

HTTP
Cache-Control: max-age=3600

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

HTTP
# 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 Modified

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

ValidatorSent back asNotes
ETag (a fingerprint of the content)If-None-MatchExact. A weak ETag (W/"...") means "the same in meaning"
Last-Modified (a date)If-Modified-SinceRough (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.

DirectiveMeaning
max-age=NFresh for N seconds (in the browser and in shared caches)
s-maxage=NLike max-age, but only for shared caches (CDNs, proxies). It overrides max-age there
publicAny cache may store it, even if it normally could not (for example, when Authorization is present)
privateOnly the user's own browser may store it, not a shared cache. Use it for personal responses
no-cacheStore it, but check with the server before every reuse. It does not mean "don't cache"
no-storeDo not store it anywhere. Use it for sensitive data
must-revalidateOnce the response is stale, do not use it without checking first (no "use the old copy if there is an error" shortcuts)
immutableThe content will never change at this URL. Skip the check, even on reload
stale-while-revalidate=NAfter the response goes stale, use the old copy for up to N seconds while a new copy loads in the background
stale-if-error=NIf 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 private or no-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

ResourceHeaderWhy
Hashed build files (app.8f3a1c.js, style.4b2d.css, /_next/static/…)Cache-Control: public, max-age=31536000, immutableThe file name changes whenever the content changes. So it is safe to cache for a year
HTML documentsCache-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 URLsmax-age=86400 + stale-while-revalidate=604800Fast, and refreshed quietly
API GET responses, public datapublic, s-maxage=60, stale-while-revalidate=300The CDN takes the traffic. Clients still see new data within about a minute
API responses, for one userprivate, no-cache (or no-store if sensitive)Never let a shared cache store someone's data
Responses that set cookies or carry secretsno-storeMust 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.

JavaScript
// 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" }] },    ];  },};
YAML
# 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

ActionWhat the browser does
Normal navigation / clicking a linkUses 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 / ForwardPrefers 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:

  1. 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 be no-cache.
  2. 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-maxage plus stale-while-revalidate, and make your pipeline clear the changed paths.
  3. Are the file names hashed? If you ship app.js with a long max-age, nothing will update it. Use hashed names.
  4. 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.
  5. 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.
  6. Check Age and the cache-status headers (x-cache, cf-cache-status) in the response. They show whether a CDN served a stored copy. Age is the number of seconds the copy has been stored.
  7. Is the API response cached too long? Check Cache-Control on the data endpoints.

What a shared CDN must never cache

  • Responses with Set-Cookie or personal data, unless the cache is keyed on the user (usually do not do this).
  • Anything tied to an Authorization header (do not add public).
  • Anything marked private or no-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 5xx cached by mistake).

Common mistakes

  1. A long max-age on HTML (users stay stuck on an old version).
  2. Mixing up no-cache and no-store.
  3. Caching personal API responses at the CDN. One user sees another user's data. This is a serious incident.
  4. Not hashing file names. Then you keep fighting "please clear your cache" support requests.
  5. Relying on heuristic caching because no header was set.
  6. Deleting old files right away on deploy.
  7. Forgetting Vary.
  8. Thinking 304 is free. It still costs a round trip. For static files, prefer immutable and a long max-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 immutable for a year. HTML gets no-cache. Personal data gets private or no-store. Public API data gets s-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

Related series