How Does Browser Caching Work: Cache-Control, ETags and Expires Headers Explained

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.

browser cache

How does browser caching work step by step?

  1. Request initiated. The browser needs a resource, for example /assets/app.4f9a1c.js.
  2. Cache lookup. It searches its memory cache first (fastest, cleared when the tab closes), then the disk cache (persistent across sessions).
  3. Freshness check. If a stored copy exists, the browser calculates whether it is still within its freshness lifetime, using Cache-Control: max-age or, as a fallback, the Expires header.
  4. 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.
  5. Revalidation (stale). If the copy has expired, the browser sends a conditional request containing If-None-Match (built from the stored ETag) and/or If-Modified-Since (built from Last-Modified).
  6. 304 or 200. The server compares the validators. Unchanged means 304 Not Modified plus refreshed headers, and the browser keeps the old bytes. Changed means a full 200 OK with 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.

browser cache

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 cheap 304. 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 with max-age on 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.

browser cache

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:

  1. Cache-Control: max-age always wins when both headers are present.
  2. It relies on clock agreement between server and client, which is fragile.
  3. Expires: 0 or 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

  1. If the URL contains a content hash, cache it forever.
  2. If the URL is stable but the content can change, use no-cache or a short max-age plus 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.

browser cache

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

  1. Open DevTools, go to the Network tab, and uncheck “Disable cache”.
  2. Load the page twice. Look at the Size column: (disk cache) or (memory cache) means a true cache hit with zero network cost.
  3. Check the Status column for 304 entries. Those are revalidations. Too many of them on static assets means your max-age is too short.
  4. Inspect response headers per request to confirm Cache-Control, ETag and Age are what you expect. The Age header tells you how many seconds the CDN has been holding that copy.
  5. From the terminal, run curl -I https://yourdomain.com/assets/app.js to see the raw headers without browser interference.
  6. Remember that a hard reload (Ctrl+Shift+R) sends Cache-Control: no-cache on the request and bypasses your configuration, so always test with a normal navigation.
browser cache

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-cache when you meant no-store. Sensitive content ends up written to disk.
  • Setting only Expires. Works, but you lose immutable, stale-while-revalidate and 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-maxage plus an instant purge API on deploy.
  • The browser HTTP cache holds a personal copy governed by max-age and 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-cache means revalidate, no-store means never store. They are not synonyms.
  • Hash your static filenames and cache them for a year with immutable.
  • Keep HTML on no-cache so 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.