What you'll learn: By the end of this lesson you will be able to explain how the browser decides to reuse, revalidate or refetch a response, read and write the main Cache-Control directives, design the "hashed filenames plus revalidated HTML" strategy, tell a browser cache from a shared cache such as a CDN, and recognise the caching bugs that break deployments.
A milk carton has a use-by date. Before it, you drink without a second thought. After it, you might phone the shop and ask whether this batch is still good. The date is set by the dairy, not by you, and a wrong date hurts either way: you bin good milk, or you drink bad milk.
HTTP caching works the same way. The server stamps each response with how long it may be reused without asking, and with a cheap way to ask afterwards. Stamp too short and users re-download files that never changed; stamp too long and they keep seeing things that did.
The running example is an online store with one JavaScript bundle (app.3f9c2e1a.js), product photos, a product API (/api/products/1234) and HTML pages (/products/1234). The file names and all lifetimes below are illustrative. For the back-end view of caches, see caching basics, advanced caching and CDN and object storage; Sibling lessons cover how a browser works, rendering strategies and loading performance; this one stays at the HTTP layer.
The core idea
Every stored response is in one of two states, defined by RFC 9111 (HTTP Caching).
Fresh: younger than its freshness lifetime. The cache may reuse it with no network request at all.
Stale: the lifetime has passed. The cache may not reuse it as is, but it can revalidate: ask the server "is my copy still current?" and, if so, get a tiny answer instead of the whole file.
From cheapest to dearest, a request is served fresh from the cache (no network), revalidated with a 304 answer (a round trip, no body), or fully downloaded. A good policy pushes requests toward the cheap end without ever showing wrong content.
The browser's HTTP cache is a private cache: it belongs to one user on one device. Between browser and server there may also be shared caches (a CDN, a reverse proxy) that serve stored responses to many users. Both obey the same Cache-Control rules, with a few directives aimed at one kind.
Figure 1. Where a response can be cached on its way to the shopper
How to read this: a request travels right only when the cache on its left cannot answer. The browser cache is the only layer that can answer with no network traffic at all.
Browsers also partition the HTTP cache so a copy cached on one site is not reused by another; Chrome began in version 86, and other browsers partition differently. A third layer, Service Workers and the Cache API, is covered near the end.
What the browser does with a request
Figure 2. The browser's decision for a cacheable request
flowchart TD
accTitle: How a browser cache handles a request
accDescr: A request first looks for a stored response. A fresh one is used with no network request. A stale one inside a stale-while-revalidate window is used while a background revalidation runs. Other stale responses with a validator are revalidated with a conditional request, and a 304 reuses the stored body while a 200 replaces it. Everything else goes to the network.
A["Request for a URL"] --> B{"Stored response?"}
B -->|no| N["Fetch from network, store if allowed"]
B -->|yes| C{"Fresh?"}
C -->|yes| U["Use stored copy, no network"]
C -->|no| S{"Inside stale-while-revalidate window?"}
S -->|yes| W["Use stale copy, revalidate in background"]
S -->|no| D{"Has ETag or Last-Modified?"}
D -->|no| N
D -->|yes| V["Send conditional request"]
V --> E{"Server answer"}
E -->|"304"| R["Update headers, use stored body"]
E -->|"200"| P["Replace stored copy, use new body"]
How to read this: the upper exits are the cheap ones. A response marked no-cache skips the "Fresh?" question and goes straight to the conditional request; one marked no-store never reaches this diagram because nothing was stored.
Freshness: how long a copy may be reused
RFC 9111 section 4.2.1 says a cache takes the first lifetime that applies: s-maxage (shared caches only), then max-age, then the older Expires date minus the response's Date.
Cache-Control: max-age=600 means fresh for 600 seconds after the origin generated the response; the clock starts at generation, not receipt. A cache that holds a copy can report its age in the Age header (RFC 9111 section 5.1). If a CDN hands a browser a response with max-age=60 and Age: 50, the browser has only about ten seconds of freshness left, so stacked caches can disagree about staleness for the same URL.
Heuristic freshness: when you give no lifetime
With no explicit lifetime, a cache may guess. RFC 9111 section 4.2.2 allows a heuristic lifetime for heuristically cacheable status codes (RFC 9110 lists 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414 and 501) and suggests a fraction of the time since Last-Modified, which "might be 10%". It gives no algorithm, so browsers choose their own. Illustratively, a file last modified 10 days ago could be treated as fresh for about one day. You do not control that guess, so send an explicit Cache-Control, as MDN advises.
Validation: asking cheaply whether a copy is still good
A stale copy can be checked with a conditional request that replays a validator the server sent earlier:
ETag: an opaque identifier of one version, such as "v7", replayed in If-None-Match. It may be weak (W/"v7": semantically equivalent rather than identical bytes; RFC 9110 section 8.8.1).
Last-Modified: a date, replayed in If-Modified-Since.
When both are sent, the server must ignore If-Modified-Since (RFC 9110 section 13.1.3). If the validator matches, the server answers 304 Not Modified with no body; the cache refreshes the stored headers and serves its stored body (RFC 9111 section 4.3.4). Otherwise the server sends a normal 200 that replaces the copy.
Figure 3. Revalidating the product API response
sequenceDiagram
accTitle: Conditional request for a stale product response
accDescr: The page asks for a product. The browser cache finds a stale copy with an ETag, sends a conditional request, and the server answers 304 with no body, so the stored body is served. If the product changed, the server answers 200 with a new body instead.
participant P as Page
participant C as Browser cache
participant S as Store server
P->>C: GET /api/products/1234
Note over C: stored copy is stale, ETag "v7"
C->>S: GET with If-None-Match "v7"
alt unchanged
S-->>C: 304 Not Modified, no body
C-->>P: stored body, headers refreshed
else changed
S-->>C: 200, new body, ETag "v8"
C-->>P: new body, copy replaced
end
How to read this: the page never sees the 304. The cache turns it into an ordinary response, so your fetch code looks the same whatever the source.
A 304 still costs a round trip, so revalidation saves bandwidth, not latency. A stale copy with no validator gives the cache nothing to ask with, so it must download again. For files that never change, the best answer is no request at all, which drives the strategy below.
The Cache-Control directives you will actually use
These are response directives. The "Defined in" column matters, because they do not all come from one document.
Directive
Defined in
What it means
max-age=N
RFC 9111
Fresh for N seconds after the origin generated the response.
s-maxage=N
RFC 9111
Like max-age but for shared caches only; overrides max-age and Expires there; private caches ignore it.
no-cache
RFC 9111
May be stored, but must be revalidated before every reuse. It does not mean "do not cache".
no-store
RFC 9111
Do not store the response or request in any cache.
public
RFC 9111
Any cache may store it, even when the request had an Authorization header.
private
RFC 9111
Only a private cache (the browser) may store it.
must-revalidate
RFC 9111
Once stale, never reuse without successful validation.
immutable
RFC 8246
Will not change while fresh, so clients should not send conditional requests in that time, such as on a normal reload.
stale-while-revalidate=N
RFC 5861
A cache may serve a stale response for N more seconds while revalidating in the background.
stale-if-error=N
RFC 5861
A cache may serve a stale response up to N seconds past freshness if the origin errors (500, 502, 503 or 504).
no-cache versus no-store is the classic trap. Use no-cache for content that must always be checked but is cheap to serve (HTML); use no-store when nothing may be kept. RFC 9111 warns that no-store is not a reliable privacy mechanism, since a misbehaving cache might ignore it. Protect secrets with access control, not a header.
The extensions come from different places. RFC 5861 is an Informational document, which RFC 9111 section 4.2.4 names as an example of extensions permitting stale responses; immutable is in RFC 8246, a Standards Track document. Support varies: web.dev reported stale-while-revalidate in Chrome 75 and Firefox 68 as of July 2019 and said a browser without support ignores it and falls back to max-age; support in other browsers today is not confirmed here. Check the MDN compatibility tables and your CDN's documentation. MDN also says stale-if-error as a request directive is not supported by any browser.
Browsers and shared caches may store it; a browser treats it as fresh for 60 seconds, a CDN for 300; after that either may serve the old copy for up to 60 more seconds while revalidating.
Browser cache versus shared cache
Question
Browser (private cache)
CDN or proxy (shared cache)
Whose copy?
One user's
Served to many users
private
May store
Must not store
s-maxage
Ignored
Used instead of max-age
Request had Authorization
Normal rules
Not reused unless the response has must-revalidate, public or s-maxage (RFC 9111 section 3.5)
Remove a bad copy
Not possible; you cannot reach into a user's browser
Usually through the provider's purge feature
The last row is the design fact. You can fix a bad CDN entry in minutes; a bad browser entry stays until it expires or the user clears it. Long browser lifetimes are safe only when the URL changes whenever the content does. A cookie alone does not make a response private (MDN), so personalised responses need an explicit private or no-store.
Vary: one URL, several responses
When one URL returns different bodies depending on a request header (language, compression), the Vary response header lists those headers. A cache must not reuse a stored response unless they match the original request's (RFC 9111 section 4.1); Vary: * never matches. MDN warns that varying on User-Agent drastically cuts reuse, and recommends Cache-Control: private rather than a cookie in Vary for personalised content.
The standard strategy: fingerprinted assets, revalidated HTML
You want the bundle cached as long as possible, but you also want a fix to reach every shopper at once. One URL cannot be both "cache forever" and "changes today".
The answer is to stop reusing URLs. Put a content hash (a short digest of the file's bytes) in the file name, called fingerprinting or cache busting. When the file changes its name changes, so to every cache it is a new resource.
// webpack.config.js (excerpt): the file name includes a hash of the contentmodule.exports = {
output: { filename: "[name].[contenthash].js" },
};
Other bundlers can do the same; check your tool's documentation. The HTML then references the hashed names:
Hashed files get a long lifetime and immutable; the HTML that names them is revalidated every time. MDN shows this pairing:
# /assets/* and /img/* (names contain a content hash)
Cache-Control: max-age=31536000, immutable
# /products/1234 (the HTML)
Cache-Control: no-cache
ETag: "page-v91"
31536000 is one year in seconds, a conventional "as long as possible" and a choice, not a requirement.
Figure 4. A deployment under this strategy
sequenceDiagram
accTitle: Returning shopper after a deployment with fingerprinted assets
accDescr: Before the deploy the browser stored the HTML and a bundle named with hash 3f9c. After the deploy the browser revalidates the HTML, receives new HTML naming a bundle with hash 8b2d, downloads only that bundle, and reuses the stored product image with no request.
participant B as Browser
participant S as Store server
Note over B: stored: HTML, app.3f9c.js, p1234.webp
Note over S: deploy ships app.8b2d.js
B->>S: GET /products/1234 with If-None-Match
S-->>B: 200, new HTML names app.8b2d.js
B->>S: GET /assets/app.8b2d.js (not stored)
S-->>B: 200, max-age one year, immutable
Note over B: p1234.webp is fresh, no request sent
How to read this: the HTML is the only mutable pointer. Revalidating it costs one small round trip and reveals the new bundle name; everything unchanged costs nothing.
Why it works:
The URL is the cache key. A long lifetime is dangerous only if a URL's content can change. Here a published URL always means the same bytes.
Only the entry point needs freshness. The small HTML is revalidated; the heavy files it points to are not.
A user gets old HTML with the old bundle or new HTML with the new one, never a mix, because the HTML decides which names load.
It makes one demand: old files must outlive the HTML that names them. A shopper's tab open since yesterday still holds yesterday's HTML. Delete yesterday's bundle on deploy and that tab breaks the next time it loads a lazy chunk.
TIP
Hash the content, not the build. A timestamp or build number in the name changes even when the file did not, and makes shoppers re-download unchanged code.
What to cache for each kind of response
Ask two questions: does the URL change when the content does, and is the response the same for every user?
Response
Example policy (illustrative)
Reasoning
Hashed JS, CSS and product images
max-age=31536000, immutable
The URL changes with the content, so a year is safe.
Image at a stable URL (a logo)
max-age=86400 plus an ETag
The name never changes, so accept up to a day of staleness (your tolerance may differ).
Short in the browser, longer in the CDN, refreshed in the background.
Fast-changing data (price, stock)
no-cache plus an ETag, or a very short max-age
Staleness is costly; the 304 keeps it cheap.
Per-user API (cart, account)
private, no-cache; no-store if sensitive
Never in a shared cache; sensitive data not stored.
Error responses
no-store or a few seconds
Never inherit the long lifetime of success.
These are example policies, not rules; the numbers are placeholders. Choose lifetimes by asking how stale each response may be before someone is harmed, as in caching basics. A CDN may also apply its own rules on top of your headers, and defaults differ by provider.
Check what is really sent:
# Headers of the hashed bundle: look for Cache-Control and Age
curl -sI https://store.example/assets/app.3f9c2e1a.js
# Replay a validator; a 304 means the server honours conditional requests
curl -sI -H 'If-None-Match: "page-v91"' https://store.example/products/1234
The second command prints a 304 Not Modified status line only if the server still holds that exact ETag.
Reload, hard reload and "Disable cache"
These are browser behaviours, not part of the HTTP specifications, and they can differ between browsers and versions. The table follows MDN and Chrome's documentation at the time of writing.
Action
What the browser typically does
Normal navigation
Uses the cache as in Figure 2: fresh copies need no request.
Reload
Sends max-age=0, so stored copies are revalidated with If-None-Match and If-Modified-Since. MDN notes Chrome no longer revalidates subresources on reload.
Hard reload
Sends Cache-Control: no-cache and Pragma: no-cache, unconditionally, so you get a 200. MDN notes Safari's requests differ.
Back and forward
Not guaranteed to revalidate; the page may be restored from the back/forward cache (bfcache).
DevTools "Disable cache"
Chrome's docs say it disables the browser cache, emulating a first-time visitor.
The same modes exist in code as the cache option of fetch (MDN, Request.cache): { cache: "no-cache" } revalidates before using a stored copy, and { cache: "reload" } skips the cache for the request but stores the response. A hard reload is what developers press when something looks wrong; shoppers never do. Test a fix with a normal navigation in a profile that already holds the old version.
Service Workers and the Cache API: a different layer
A Service Worker is a background script that can intercept your page's requests; the Cache API is a store your JavaScript fills and empties. It is not the HTTP cache: it has no freshness rules, so whatever you put there stays until your code removes it. A service worker answers first; if it calls fetch(), the HTTP cache rules above still apply beneath it (web.dev). Offline support and update strategies are a separate topic.
Common caching bugs
Stale HTML pointing at deleted bundles. HTML is cached for long somewhere (a CDN rule, a heuristic) and a deploy removes the bundle names it mentions, leaving a blank page. Fix: no-cache HTML everywhere and keep old assets for a while.
Caching an error. A response is storable if its status is final and it carries an explicit lifetime such as max-age (RFC 9111 section 3), so a blanket one-year rule under /assets/ also covers a 404 or 503 from a bad deploy. A 404 is even heuristically cacheable with no header. Fix: long lifetimes only on success responses.
Caching a personalised response in a shared cache. The CDN stores shopper A's logged-in page and serves it to shopper B, usually because of public, an s-maxage, or a CDN rule that overrides the origin. Fix: private on anything user-specific.
In practice: caching for an online store's front end
NOTE
This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.
The situation
A mid-sized store has a React single-page front end, a Node.js API and a CDN in front of both, owned by a small platform team. The build produces app.js and app.css at fixed names. Years ago someone added a CDN rule: "cache everything for one hour". Support now hears that after releases some shoppers "see old prices" or "get a blank page until they clear the cache".
Starting architecture
Figure 5. The store before the fix: one blanket rule
How to read this: three kinds of response needing very different freshness pass through one rule, which overrides the origin's own headers, so the application cannot say what it wants.
Options considered
Option
What it fixes
Risk
Shorten the blanket rule to a few minutes
Narrows the stale window
Bundles still unhashed, so old and new files can mix; lower hit ratio
Purge the CDN on every deploy
Fixes the CDN layer
Does nothing for copies already in browsers
Hashed assets, revalidated HTML, short-lived API
Fixes browser and CDN layers
Build change, keep old assets, needs a careful rollout and header tests
The decision
Field
Decision record
Context
One CDN rule gives HTML, bundles and API data the same lifetime, and bundles are not versioned.
Decision
Hash JS, CSS and image names. Hashed files: max-age=31536000, immutable. HTML: no-cache with an ETag. Product API: max-age=60, s-maxage=300, stale-while-revalidate=60. Cart and account: private, no-store. Remove the CDN rule so origin headers are respected. Errors cached at most a few seconds.
Consequences, good
Returning shoppers rarely re-download code; a deploy reaches everyone on their next page load; stale prices are bounded by the API lifetime.
Consequences, bad
The origin must keep previous releases' assets; header policy becomes code to review; HTML adds a revalidation round trip per navigation.
Revisit when
The CDN hit ratio for the API drops sharply, or stale-page reports continue a week after rollout.
Rollout and verification
Roll out in stages: ship hashed file names with the old rule still in place; publish the new headers for hashed files; switch HTML to no-cache with an ETag; remove the blanket rule. At each step run curl -sI against the CDN URL, not only the origin, and watch the hit ratio per path group, the share of 304s on HTML, script errors and 404s on /assets/. Add a pipeline test that fails the build if the Cache-Control of one hashed file, the HTML and one API URL differs from the expected value; header policy drifts silently otherwise.
The 2 a.m. failure
A release ships at 02:00. A clean-up job written to save storage deletes the previous release's bundles immediately. One legacy HTML path, the landing page, still carries an s-maxage from the old rule and sits in the CDN for up to an hour.
Figure 6. What the browser sees after the clean-up
sequenceDiagram
accTitle: Cached HTML references a deleted bundle
accDescr: A browser asks the CDN for the landing page. The CDN serves an old cached copy, which names a bundle the clean-up job deleted. The browser requests that bundle, the origin answers 404, and the page stays blank.
participant B as Browser
participant C as CDN
participant O as Origin
Note over O: deploy deletes app.3f9c.js
B->>C: GET /landing
C-->>B: cached HTML (Age: 1800), names app.3f9c.js
B->>C: GET /assets/app.3f9c.js
C->>O: miss, fetch
O-->>C: 404
C-->>B: 404, page stays blank
How to read this: the failure needs two mistakes at once, a cached HTML page and a deleted asset. Either alone is survivable.
The alert is "script load errors up and 404s on /assets/ rising right after a release". First three checks: request the landing page through the CDN and read Cache-Control and Age; compare the bundle name in that HTML with the files on the origin; list the paths still matched by the old CDN rule. Mitigation: purge the affected HTML paths and redeploy the previous bundles. Follow-ups: retain the last few releases' assets, delete the legacy rule, and keep the header test.
Architect's notes
Decide freshness per response type first. Hashed assets, HTML, public data and personal data are four policies; one rule for all is the root of most caching incidents.
Treat headers as an interface.Cache-Control is a contract with every cache downstream. Review it like an API and test it in CI.
Know which layer you are debugging. Browser, CDN and service worker cache independently; read Age and the response's Cache-Control first.
Defer extensions. Add stale-while-revalidate or stale-if-error when a measured problem calls for them, after confirming support.
Measure. CDN hit ratio, the share of 304s on HTML, repeat-visit transfer size and error rates after deploys; Loading Performance and Core Web Vitals covers wider measurement.
Common mistakes
Reading no-cache as "do not cache". It means "store, but revalidate". Use no-store to prevent storage.
Long lifetimes on HTML, or on URLs that never change name.
Relying on heuristic freshness, which is each browser's guess.
A timestamp or build number instead of a content hash, which defeats reuse.
Blanket headers on every response, errors included.
Testing only with a hard reload or "Disable cache".
Key takeaways
A cached response is fresh (reuse, no request) or stale (revalidate or refetch). Lifetime comes from s-maxage, then max-age, then Expires.
Validators (ETag, Last-Modified) make revalidation cheap; a 304 saves bandwidth but still costs a round trip.
no-cache means "revalidate before reuse"; no-store means "do not keep it".
immutable (RFC 8246), stale-while-revalidate and stale-if-error (RFC 5861) are extensions: check browser and CDN support.
Fingerprint file names, give hashed files a long lifetime and immutable, serve HTML with no-cache and an ETag, and keep old assets until no cached HTML can name them.
private keeps responses out of shared caches; s-maxage is read only by shared caches.
A hard reload hides real behaviour; test as a returning user.
The header sets here are example policies; derive yours from how stale each response may be.
Practice exercises
Exercise 1 — Starter (30 min): In DevTools on any site, find one script, one image and the HTML document. Write down their Cache-Control, ETag or Last-Modified, and Age. For each, say which freshness rule applies and whether the name suggests fingerprinting.
Exercise 2 — Intermediate (90 min): Write a small local server with a hashed asset, an HTML page and a JSON API, each with a policy from the decision table and an ETag on the HTML and API. Use curl -sI with and without If-None-Match to confirm you get 304 where expected, then observe a normal reload and a hard reload in the browser.
Exercise 3 — Advanced (half day): Reproduce the 2 a.m. failure locally: serve HTML with a shared-cache lifetime, change the bundle name, delete the old file, and watch a browser holding the old HTML fail. Fix it two ways (headers, and retaining old assets) and say which you would ship first and why.
Lesson 6 of 8 · Frontend System Design Foundations: How the Web Runs
Learn how freshness, validation and Cache-Control work in the browser cache, and choose header policies for hashed assets, HTML pages and APIs.
In the rest of this lesson
The Cache-Control directives you will actually use
Browser cache versus shared cache
The standard strategy: fingerprinted assets, revalidated HTML
What to cache for each kind of response
Reload, hard reload and "Disable cache"
Service Workers and the Cache API: a different layer
Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.