
In the previous article on HTTP evolution, we mentioned the two cache validation mechanisms introduced in HTTP/1.1 — Last-Modified / If-Modified-Since and ETag / If-None-Match. But the focus there was on "the evolution of the HTTP protocol itself" — caching was only mentioned in passing.
In practice, however, caching may be the only aspect of HTTP you directly interact with on a daily basis. User reports that "the page didn't update" after a deployment? Nine times out of ten, the caching configuration is wrong. Filename hashes in your build tool output? They're not for aesthetics — they exist to make caching work correctly. CDN purging, hard reloads, force refreshes — all of these everyday actions are about wrestling with HTTP caching.
This article starts from the browser's complete cache decision tree and systematically breaks down the two-tier HTTP caching system — strong cache and negotiated cache — along with their practical applications in front-end engineering.
Cache-Control and ExpiresETag / If-None-Match and Last-Modified / If-Modified-Sinceno-cache and no-store — the single most confusing interview question on this topicUnderstand HTTP/1.1's basic request-response model and header field mechanism
You've deployed a new React project to production. A user visits app.example.com for the first time, loading main.js, vendor.js, and style.css. They leave the page, then return five minutes later. This time, are those three files re-downloaded, or are they served from the local cache?
The answer depends on your caching configuration. If you haven't configured anything (no Cache-Control header), the browser uses heuristic caching — estimating freshness lifetime based on the difference between the Last-Modified time and the current time. This essentially means "behavior varies by browser," and you shouldn't rely on it.
A more common scenario: your CI/CD pipeline adds a content hash to every JS filename (e.g., main.abc123.js). The user downloads main.abc123.js on their first visit. You fix a bug, redeploy, and the filename becomes main.def456.js. The user visits again — is the old abc123 still usable from cache? How does the new def456 get loaded? This is the core challenge of front-end caching strategy.
Let's start with the browser's cache decision flow.
When the browser is about to initiate an HTTP request, it first checks the local cache. Here's the decision process:
Browser requests resource
│
▼
┌─ Has local cached copy?
│ │
No Yes
│ │
▼ ▼
Direct Is the cached copy
request still fresh (within max-age)?
│ │
│ Yes No
│ │ │
│ ▼ ▼
│ Strong Does the cached
│ cache copy have ETag
│ (use it) or Last-Modified?
│ │
│ Yes No
│ │ │
│ ▼ ▼
│ Negotiated Direct
│ cache request
│ (conditional
│ request)
│ │
│ ┌─── 304? ───┐
│ │ │
│ Yes No
│ │ │
│ ▼ ▼
│ Refresh Download
│ freshness new resource
│ use local update cache
│ │
└────────┴──────────→ Display resourceTwo major branches:
200 (from disk cache) or 200 (from memory cache).304 Not Modified (no body). If changed, it returns 200 with the new content.The core idea of strong caching: for a certain period, this resource definitely won't change — don't bother asking me.
Cache-Control was introduced in HTTP/1.1 and is the standard way to control caching today. The vast majority of caching behavior on the web is defined by it.
Core directives:
| Directive | Meaning | Example |
|---|---|---|
max-age=N | Resource is fresh for N seconds | Cache-Control: max-age=3600 |
s-maxage=N | Like max-age but only for shared caches (CDN) | Cache-Control: s-maxage=86400 |
public | Can be stored by any cache (browser, CDN, proxy) | Cache-Control: public, max-age=86400 |
private | Only cached by the browser, not by CDN/proxy | Cache-Control: private, max-age=600 |
no-cache | Cache it, but validate with server before each use | Cache-Control: no-cache |
no-store | Don't cache at all; re-download every time | Cache-Control: no-store |
must-revalidate | After expiry, must validate with server; stale cache not allowed | Cache-Control: must-revalidate |
immutable | Resource will never change; don't revalidate even on hard reload | Cache-Control: max-age=31536000, immutable |
no-cache vs no-store — the most confusing interview question:
no-cache: ❌ Does NOT mean "don't cache"
✅ Means "cache it, but ask the server before each use"
→ The browser caches the resource, but sends a conditional request each time
no-store: ✅ Actually means "don't cache"
→ The browser stores nothing; every request downloads the full contentA handy way to remember: no-cache = "cache with revalidation"; no-store = "don't cache at all."
Real-world use cases:
# 1. Static assets with content hash (JS/CSS/images) — never expire
Cache-Control: public, max-age=31536000, immutable
# 2. User profile page — browser cache only, expires in 5 minutes
Cache-Control: private, max-age=300
# 3. Sensitive banking page — never cache
Cache-Control: no-store
# 4. HTML entry file — cache but validate every time
Cache-Control: no-cacheWhy should the HTML entry file use no-cache? Because the HTML references main.abc123.js. If you change your code, the JS filename becomes main.def456.js — but if the user's browser has the old HTML cached (still referencing abc123), they'll never load the new JS. no-cache makes the browser ask the server every time: "Has the HTML changed?" If yes, it gets the new HTML (referencing def456, then the browser downloads the new JS). If unchanged, it uses the local copy.
Expires is a relic from the HTTP/1.0 era, using absolute timestamps:
Expires: Wed, 21 Oct 2026 07:28:00 GMTIt has one fatal flaw: it depends on clock synchronization between client and server. If the user's computer clock is one hour behind the server's, this cache "lives" an extra hour. In contrast, Cache-Control: max-age=N uses relative time (N seconds), independent of clock drift, making it far more reliable.
When both Cache-Control and Expires are present, Cache-Control takes precedence.
Open Chrome DevTools → Network panel, look at the Size column:
(disk cache) / (memory cache): strong cache hit — no request was made245 KB): data went over the network — either a negotiated cache hit (304) or a full download (200)You don't need to control memory cache vs disk cache — the browser decides where to store resources. Rough rule of thumb: large resources like images and fonts go to disk cache; Base64 inline images and small JS snippets go to memory cache.
The problem with strong caching: if you set max-age=86400 (one day) but push an emergency bug fix mid-day, all users with unexpired caches won't get the fix.
Negotiated caching solves this: let the cache expire, but don't throw it away — take its "fingerprint" to the server and ask: "Is the version matching this fingerprint still valid?"
ETag (Entity Tag) is a content-based version identifier. The server assigns a unique string to each version of a resource:
# First request — response
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: max-age=3600
# One hour later, cache expired — browser sends conditional request
GET /app.js HTTP/1.1
If-None-Match: "abc123"
# Server compares ETag — unchanged
HTTP/1.1 304 Not Modified
ETag: "abc123"
Cache-Control: max-age=3600
# ← No body — browser keeps using the local cache, refreshes freshness lifetime
# Server compares ETag — changed
HTTP/1.1 200 OK
ETag: "def456"
Cache-Control: max-age=3600
<body>new content</body>ETag generation strategy depends on the server implementation:
last-modified time + content-lengthKey point: ETag depends on content, not time. If you change a file's content and then revert it (e.g., git checkout to an older version), the ETag reverts too. Last-Modified can't do that — if the file gets touched, the timestamp changes even though the content didn't.
Another negotiation mechanism, based on time rather than content:
# First request — response
HTTP/1.1 200 OK
Last-Modified: Tue, 02 Jun 2026 10:00:00 GMT
# Next request
GET /app.js HTTP/1.1
If-Modified-Since: Tue, 02 Jun 2026 10:00:00 GMT
# Server compares timestamps
HTTP/1.1 304 Not ModifiedLast-Modified has two shortcomings:
Second-level precision only: if a file is modified twice within the same second (e.g., automated deployment writes two versions within 1s), Last-Modified can't distinguish them, and the browser may serve stale content.
False modification triggers cache invalidation: the file content hasn't changed, but the mtime (modification time) has — for example, when CI re-clones a repository, all file mtime values get updated. The browser thinks the files changed when they actually didn't.
This is why ETag is the recommended approach for negotiated caching, with Last-Modified as a fallback. When both are present, the browser sends both conditional headers, and the server prioritizes ETag for the decision.
Combining strong cache and negotiated cache, here's the complete flow for a resource request:
Cache-Control: max-age or Expiresno-cache) → send conditional requestFront-end build tools (Webpack, Vite, esbuild) offer three hash modes:
// webpack.config.js
output: {
filename: '[name].[contenthash].js', // content changes → hash changes
// or
filename: '[name].[chunkhash].js', // chunk content changes → hash changes
// or
filename: '[name].[hash].js', // any build output changes → hash changes
}Recommendation: [contenthash] — only when the file's own content changes does its hash change. This is the foundation of immutable caching:
First deployment:
main.abc123.js → Cache-Control: max-age=31536000, immutable
vendor.def456.js → Cache-Control: max-age=31536000, immutable
You change business code, redeploy:
main.ghi789.js → New file! User loads new main.js
vendor.def456.js → Hash unchanged! URL unchanged! Browser keeps using cacheThe key logical chain:
max-age=31536000 (one year)This is the standard combination in modern front-end deployment:
/ (index.html) → Cache-Control: no-cache (or max-age=0)
/static/js/main.*.js → Cache-Control: public, max-age=31536000, immutable
/static/css/style.*.css → Cache-Control: public, max-age=31536000, immutable
/static/img/*.png → Cache-Control: public, max-age=31536000Why is this combination safe?
A CDN sits as a caching layer between users and your origin server. When coordinating your assets with CDN caching:
User → CDN → Origin
↑
Cache here too!Cache-Control headers to update browser caching behavior as well(disk cache) / (memory cache) and which resulted in 304 requests.curl -I https://example.com/main.css to inspect response headers and identify Cache-Control, ETag, and Last-Modified configurations.Cache-Control: max-age=60 and ETag separately, then observe behavioral differences on the second and third visit in the browser.output.filename to [name].js and [name].[contenthash].js respectively, deploy, and observe the caching behavior differences.index.html use immutable? What would happen if your SPA entry HTML were cached for a year?Cache-Control: max-age=N is the standard way to implement strong caching; Expires is obsoleteno-cache ≠ don't cache — it means "cache but validate every time"; no-store truly means "don't cache"ETag / If-None-Match (content-based) over Last-Modified (second-level precision only)no-cache, JS/CSS with content hash + max-age=31536000, immutableThis article focused on the core practice of HTTP caching in the browser and in front-end engineering. But there's another crucial piece to the caching puzzle — CDN and reverse-proxy layer caching, including s-maxage, stale-while-revalidate, the practical uses of the Vary header, and how the Service Worker Cache API lets front-end developers take full control of caching logic.
In the next article, we dig deeper into caching — CDN and reverse-proxy layer caching strategies, including how s-maxage issues different directives to CDN vs. browser, how stale-while-revalidate enables "serve stale while refreshing", the Vary header for response differentiation, and how the Service Worker Cache API lets front-end developers take full control of caching logic.