Every time a visitor loads a page on your site, the browser has a choice: download the file again, ask the server if its copy is still good, or use what it already has on disk without saying a word. That choice is decided almost entirely by the HTTP headers you send. So how does browser caching work in practice, and which headers should you actually set for each type of asset? Related reading: How does browser caching currently work.
This guide breaks down the caching lifecycle step by step, shows real header examples for Cache-Control, ETag and Expires, and ends with a decision table you can copy straight into your server or CDN configuration.
Browser caching in one paragraph
A browser cache is a local storage area on the user’s device where copies of downloaded files (HTML, CSS, JavaScript, images, fonts, API responses) are kept. When the same resource is requested again, the browser first checks whether it already has a usable copy. If that copy is still fresh, it is served instantly from memory or disk with zero network traffic. If it is stale, the browser sends a small conditional request to ask the server whether anything changed. If nothing changed, the server replies 304 Not Modified with an empty body, and the cached file is reused. The result is faster page loads, lower bandwidth bills and less origin server load.

How does browser caching work step by step?
- Request initiated. The browser needs a resource, for example
/assets/app.4f9a1c.js. - Cache lookup. It searches its memory cache first (fastest, cleared when the tab closes), then the disk cache (persistent across sessions).
- Freshness check. If a stored copy exists, the browser calculates whether it is still within its freshness lifetime, using
Cache-Control: max-ageor, as a fallback, theExpiresheader. - Cache hit (fresh). The file is used immediately. DevTools shows (from disk cache) or (from memory cache) and the status stays 200. No request leaves the machine.
- Revalidation (stale). If the copy has expired, the browser sends a conditional request containing
If-None-Match(built from the storedETag) and/orIf-Modified-Since(built fromLast-Modified). - 304 or 200. The server compares the validators. Unchanged means
304 Not Modifiedplus refreshed headers, and the browser keeps the old bytes. Changed means a full200 OKwith a new body, which replaces the cached entry.
The key mental model: freshness avoids the network entirely, validation avoids the download but still costs one round trip. Good caching strategy is mostly about pushing as many requests as possible into the first category without ever serving outdated content.
Where does the browser actually store files?
| Layer | Lifetime | Controlled by |
|---|---|---|
| Memory cache | Current tab session | Browser heuristics |
| Disk (HTTP) cache | Days to months, subject to eviction | HTTP response headers |
| Service Worker Cache API | Until your code deletes it | Your JavaScript |
| CDN / shared proxy cache | Per edge configuration | s-maxage, purge API |
Only the HTTP cache and the shared cache respond to the headers described below. Service worker caches ignore them unless your fetch handler chooses to respect them.

Cache-Control: the header that decides everything
Cache-Control is the primary caching header in modern HTTP. It is a comma separated list of directives on the response.
The directives you will actually use
| Directive | What it really does |
|---|---|
max-age=N |
The response stays fresh for N seconds after it was generated. During that window the browser will not contact the server at all. |
s-maxage=N |
Same idea, but only for shared caches (CDN, proxy). Overrides max-age there. |
no-cache |
Store the file, but revalidate with the server before every reuse. It does not mean “do not cache”. |
no-store |
Never write this response to disk or memory. Reserved for sensitive data. |
must-revalidate |
Once stale, the cache may not serve the copy without a successful revalidation, even if the network is down. |
private |
Only the end user’s browser may store it. CDNs and proxies must not. |
public |
Any cache may store it, even when the request carried an Authorization header. |
immutable |
Promises the bytes will never change at this URL, so the browser skips revalidation even on a reload. |
stale-while-revalidate=N |
Serve the stale copy instantly for up to N extra seconds while refreshing it in the background. |
no-cache vs no-store vs must-revalidate
These three are the most misunderstood directives on the web, so here they are side by side:
no-cache: “Keep it, but always ask me first.” Usually returns a cheap304. Perfect for HTML documents.no-store: “Do not keep it at all.” Every request is a full download. Use for banking pages, personal dashboards, one time tokens.must-revalidate: “You may reuse it while fresh, but the moment it goes stale you must check with me, and if I am unreachable, return an error instead of serving old data.” Pairs well withmax-ageon short lived content.
ETag and Last-Modified: how revalidation works
Freshness tells the browser how long to trust a copy. Validators tell the server whether the copy is still correct once that time is up. Much the same conclusion turns up on cloudflare.com.
ETag (strong validator)
An ETag is an opaque identifier for a specific version of a resource, typically a hash of the file contents.
First response from the server:
HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=600
ETag: "a17f4b2c9e"
Content-Length: 48210
Ten minutes later, the browser needs the file again. The copy is stale, so it asks:
GET /assets/theme.css HTTP/1.1
Host: adproductstogo.com
If-None-Match: "a17f4b2c9e"
Nothing changed, so the server answers with no body at all:
HTTP/1.1 304 Not Modified
ETag: "a17f4b2c9e"
Cache-Control: public, max-age=600
The browser reuses the 48 KB it already has and resets the freshness clock. Bandwidth saved: everything except a few hundred bytes of headers.
Last-Modified (weak validator)
If no ETag is available, the browser falls back to the Last-Modified timestamp and sends If-Modified-Since. It only has one second resolution and breaks when a file is regenerated without content changes (common with deploy pipelines that touch every file). Prefer ETags, and keep Last-Modified as a secondary signal.
Weak vs strong ETags
An ETag prefixed with W/, for example W/"a17f4b2c9e", is weak: it means the two versions are semantically equivalent but not byte identical. Weak ETags cannot be used for range requests. Gzip and Brotli compression at the proxy layer often converts strong ETags to weak ones, which is normal and harmless for most sites.

Expires: the legacy header
Before Cache-Control existed, freshness was expressed with an absolute date:
Expires: Wed, 30 Sep 2026 10:00:00 GMT
Three things to remember about Expires:
Cache-Control: max-agealways wins when both headers are present.- It relies on clock agreement between server and client, which is fragile.
Expires: 0or a date in the past means “already stale”, which triggers revalidation rather than blocking caching.
Keep it only as a fallback for very old proxies. Every current browser handles Cache-Control, so that should be your source of truth.
The decision table: which headers to set for each asset type
This is the part most articles skip. Below is a practical mapping you can apply directly. The strategy assumes your build tool adds content hashes to static filenames, which is standard in Vite, webpack, Next.js, Laravel Mix and most modern toolchains.
| Resource | Recommended header | Why |
|---|---|---|
| HTML documents | Cache-Control: no-cache + ETag |
The HTML references the hashed asset URLs, so it must never be stale. Revalidation returns a tiny 304 most of the time. |
| Hashed CSS / JS bundles app.4f9a1c.js |
Cache-Control: public, max-age=31536000, immutable |
The URL changes whenever the content changes, so a one year lifetime is safe and eliminates all revalidation traffic. |
| Unhashed CSS / JS style.css |
Cache-Control: public, max-age=3600, must-revalidate + ETag |
Short freshness window limits how long a broken deploy can linger, ETag keeps refreshes cheap. |
| Images, logos, product photos | Cache-Control: public, max-age=2592000 (30 days) |
Rarely change. If you need instant updates, version the filename or add a query string. |
| Fonts (woff2) | Cache-Control: public, max-age=31536000, immutable |
Fonts are effectively永 static and heavy, so cache them for as long as possible. |
| Favicons, manifest | Cache-Control: public, max-age=86400 |
One day is a good compromise between stability and update speed. |
| Public API / JSON, read only | Cache-Control: public, max-age=60, stale-while-revalidate=300 |
Instant responses for repeat calls, background refresh keeps data close to live. |
| Personalised API responses | Cache-Control: private, no-cache + ETag + Vary: Authorization |
Never let a CDN store one user’s data, but still allow cheap 304 revalidation. |
| Sensitive pages (checkout, account) | Cache-Control: no-store |
Nothing should survive on a shared or public device. |
| POST / PUT / DELETE responses | Cache-Control: no-store |
State changing responses should never be replayed from cache. |
The two rule summary
- If the URL contains a content hash, cache it forever.
- If the URL is stable but the content can change, use
no-cacheor a shortmax-ageplus an ETag.
Almost every caching bug on the web comes from breaking one of those two rules, usually by caching HTML aggressively or by shipping unhashed bundle names.
Don’t forget the Vary header
Caches key entries by URL. If your server returns different bodies for the same URL depending on a request header, you must declare it:
Vary: Accept-Encoding, Accept-Language
Without this, a visitor can receive a French page in an English session, or a Brotli payload their client cannot decode. Keep the Vary list as short as possible: Vary: User-Agent in particular shreds cache hit rates because there are thousands of distinct user agent strings.

Cache busting done right
Long max-age values are only safe if you can change the URL when the content changes. Three approaches, best first:
- Content hash in the filename (
main.9c1b7d.css): the cleanest option, supported natively by every modern bundler. - Version folder (
/v42/app.js): simple, but invalidates everything on every release. - Query string (
app.js?v=42): works in browsers, but some intermediary caches historically ignore query strings on static files. Acceptable for CMS themes where filenames cannot change.
How to test your caching setup
- Open DevTools, go to the Network tab, and uncheck “Disable cache”.
- Load the page twice. Look at the Size column: (disk cache) or (memory cache) means a true cache hit with zero network cost.
- Check the Status column for
304entries. Those are revalidations. Too many of them on static assets means yourmax-ageis too short. - Inspect response headers per request to confirm
Cache-Control,ETagandAgeare what you expect. TheAgeheader tells you how many seconds the CDN has been holding that copy. - From the terminal, run
curl -I https://yourdomain.com/assets/app.jsto see the raw headers without browser interference. - Remember that a hard reload (Ctrl+Shift+R) sends
Cache-Control: no-cacheon the request and bypasses your configuration, so always test with a normal navigation.

Common caching mistakes
- Caching HTML for hours. Users end up loading an old document that points to deleted asset URLs, resulting in blank pages after a deploy.
- Using
no-cachewhen you meantno-store. Sensitive content ends up written to disk. - Setting only
Expires. Works, but you loseimmutable,stale-while-revalidateand shared cache control. - Serving assets from multiple hostnames. The same file cached under two origins doubles the storage and halves the hit rate.
- Forgetting
Vary: Accept-Encoding. Compression negotiation quietly breaks behind proxies. - Disabling ETags entirely on multi server setups. Instead of removing them, make sure all nodes generate the ETag from the file contents, not the inode.
Browser cache, CDN cache and service workers together
A well tuned stack layers all three:
- The CDN holds a shared copy for all visitors and is controlled with
s-maxageplus an instant purge API on deploy. - The browser HTTP cache holds a personal copy governed by
max-ageand validated with ETags. - A service worker can add offline support and precaching, but be careful: it sits in front of the HTTP cache and can serve outdated files long after you fixed the headers. Always ship a cache versioning and cleanup routine with it.
FAQ
How long does a browser cache last?
Exactly as long as you tell it to. With Cache-Control: max-age=31536000 a file can stay fresh for a year, though browsers evict entries when disk quota is reached or the user clears their data. Without any caching headers, browsers apply heuristic caching, usually about 10% of the time elapsed since Last-Modified, which is unpredictable. Always set explicit headers.
What is the 80/20 rule in caching?
It is the observation that roughly 80% of requests target about 20% of your resources. Those hot items (your main bundle, logo, hero image, fonts, top product pages) deliver most of the benefit, so cache them hardest first instead of trying to optimise every single URL.
Is there a downside to clearing your cache?
Yes. The next visit to every site has to re download all assets, so pages load slower and mobile data usage spikes. Clearing site data can also sign you out and reset preferences. For developers it is a useful troubleshooting step, but a hard reload or a private window is usually enough.
How do I stop the browser from caching a file?
Send Cache-Control: no-store for content that must never persist. If you just want the browser to check with you every time while still allowing 304 responses, use Cache-Control: no-cache together with an ETag. For a one off refresh of a static asset, change the URL.
Does browser caching help SEO?
Indirectly but meaningfully. Caching improves repeat view load times and stability, which feeds into Core Web Vitals such as LCP and INP. Faster pages also reduce bounce rate and let crawlers fetch more of your site within the same crawl budget.
What is the difference between ETag and Last-Modified?
ETag is a content based fingerprint and detects any byte level change. Last-Modified is a timestamp with one second precision that can report a change even when the content is identical. When both are present, browsers prefer the ETag.
Why do I still see 200 responses instead of 304?
Either the resource is still fresh (a 200 marked “from disk cache” is a genuine cache hit and is the ideal outcome), or your server is not returning validators, or a proxy is stripping the ETag. Check the raw headers with curl to find out which.
Key takeaways
- Freshness (
max-age,Expires) skips the network completely. Validation (ETag,Last-Modified) skips the download only. no-cachemeans revalidate,no-storemeans never store. They are not synonyms.- Hash your static filenames and cache them for a year with
immutable. - Keep HTML on
no-cacheso users always get the correct asset references. - Verify everything in DevTools with “Disable cache” turned off before you call it done.
Set these headers once, per asset class, and browser caching stops being a mystery and starts being one of the cheapest performance wins available to your team.

