Why HTTP Caching Matters
HTTP caching is one of the highest-leverage performance optimizations available. A properly cached resource requires zero server processing, zero database queries, and zero bytes transferred — it's served instantly from the browser or CDN.
Done wrong, caching causes users to see stale content for hours or days after a deployment. This guide covers every important header and how they interact.
How Browser Caching Works
When a browser receives a response, it decides whether to cache it based on headers. On a subsequent request, it checks the cache first:
- Fresh cache hit: Use cached response directly — no network request
- Stale cache (revalidation needed): Send a conditional request to the server
- No cache / expired: Fetch the full response from the server
Cache-Control: The Primary Header
Cache-Control is the primary caching directive. It replaces the older Pragma and Expires headers.
Common Directives
Cache-Control: max-age=3600
Response can be used for 3600 seconds (1 hour) without revalidation.
Cache-Control: no-cache
Misleading name. Does NOT mean "don't cache." It means "cache it, but always revalidate before using." The browser stores the response and sends a conditional request (with ETag or Last-Modified) every time before using it.
Cache-Control: no-store
Truly never cache. No copy stored anywhere. Use for responses containing sensitive user data.
Cache-Control: public
Response can be stored by any cache (browser, CDN, proxy). Default for responses without credentials.
Cache-Control: private
Only the end user's browser should cache this. Prevents CDNs from caching user-specific responses.
Cache-Control: immutable
Tell the browser this resource will NEVER change. Skip revalidation even when the user manually refreshes. Use only for content-hashed assets.
Cache-Control: stale-while-revalidate=60
Serve stale content for up to 60 seconds while fetching an update in the background. Great for APIs where slightly stale data is acceptable.
Cache-Control: stale-if-error=86400
If the revalidation request fails (server error, network timeout), use stale content for up to 86400 seconds. Improves resilience.
Combining Directives
Cache-Control: public, max-age=31536000, immutable
For content-hashed static assets (JS, CSS with hash in filename): cache forever, everywhere, skip revalidation.
Cache-Control: private, no-cache
For user-specific HTML pages: browser caches but revalidates every time.
Cache-Control: no-store
For bank statements, medical records, sensitive downloads: never cache.
ETags: Efficient Revalidation
An ETag (Entity Tag) is a version identifier for a resource — typically a hash of the content or a version number.
Flow:
- Server sends:
ETag: "abc123" - Browser caches the response
- Next request, browser sends:
If-None-Match: "abc123" - If unchanged: server returns
304 Not Modified(empty body, very fast) - If changed: server returns
200 OKwith new content and new ETag
// Express — ETag middleware (built-in, enabled by default)
app.use(express.static('public')); // serves with ETags automatically
// Manual ETag
const crypto = require('crypto');
app.get('/api/data', (req, res) => {
const data = getDataFromDB();
const etag = '"' + crypto.createHash('md5').update(JSON.stringify(data)).digest('hex') + '"';
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, no-cache'); // revalidate every time
res.json(data);
});
Strong vs Weak ETags
Strong ETag ("abc123"): byte-for-byte identical content. Cannot be used if response varies by content-encoding.
Weak ETag (W/"abc123"): semantically equivalent but may differ in minor ways (e.g., gzipped vs uncompressed). More flexible.
Last-Modified: The Legacy Approach
Before ETags, Last-Modified was used for revalidation.
Response: Last-Modified: Mon, 26 May 2026 10:00:00 GMT
Request: If-Modified-Since: Mon, 26 May 2026 10:00:00 GMT
Response: 304 Not Modified
ETags are preferred because timestamps have 1-second resolution (files updated within the same second cause issues) and don't capture content changes that don't update the filesystem timestamp.
Many servers send both — ETags take precedence.
The Vary Header
Vary tells caches that different request headers produce different responses. If a cache stores one version, it can only use it for requests with matching header values.
Vary: Accept-Encoding
Cache separate versions for gzip, br, and identity-encoded clients.
Vary: Accept-Language
Cache separate versions for each language. Useful for internationalized content.
Vary: Authorization
⚠️ Don't do this. It creates a separate cache entry for every different Authorization value — effectively disabling caching.
Caching Strategy by Resource Type
Static Assets with Hash in Filename
main.abc123.js, styles.def456.css
Cache-Control: public, max-age=31536000, immutable
Cache for 1 year, CDN-cacheable, never revalidate. When content changes, the hash in the filename changes, so the browser always gets the new file. This is cache busting.
HTML Pages
Cache-Control: no-cache
Always revalidate. HTML pages reference the hashed assets — keeping HTML fresh ensures users get the latest asset filenames.
API Responses (User-specific)
Cache-Control: private, no-cache
With ETag: browser caches and revalidates, saving bandwidth if data hasn't changed.
API Responses (Public, infrequent updates)
Cache-Control: public, max-age=300, stale-while-revalidate=30
Cache 5 minutes, serve stale for 30 more seconds while refreshing in background.
Sensitive Data
Cache-Control: no-store
Banking, healthcare, PII — never store a copy.
Configuring Cache Headers
Nginx
# Long cache for hashed assets
location ~* .(js|css|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Short cache for HTML
location ~* .html$ {
add_header Cache-Control "no-cache";
}
# API responses — no CDN cache, browser revalidates
location /api/ {
add_header Cache-Control "private, no-cache";
add_header Vary "Authorization";
}
Express
const express = require('express');
const path = require('path');
const app = express();
// Static assets with hash — immutable cache
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), {
maxAge: '1y',
immutable: true,
}));
// HTML pages — always revalidate
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache');
}
},
}));
// API routes — private, revalidate
app.get('/api/*', (req, res, next) => {
res.setHeader('Cache-Control', 'private, no-cache');
next();
});
Cache Busting Strategies
Filename hashing (recommended): Include a content hash in the filename. Vite, webpack, and most bundlers do this automatically.
Before: /assets/main.js
After: /assets/main.Bx9k2Ar7.js ← hash changes when content changes
Query string versioning: Append a version parameter. Less effective — some caches ignore query strings.
<script src="/assets/main.js?v=2.1.3"></script>
URL versioning: Include a version in the path. Reliable but requires updating HTML on every release.
/v2/assets/main.js
Quick Reference
| Resource Type | Recommended Cache-Control |
|---|---|
| Content-hashed JS/CSS | public, max-age=31536000, immutable |
| Images/fonts (hashed) | public, max-age=31536000, immutable |
| HTML pages | no-cache |
| API (public data) | public, max-age=300, stale-while-revalidate=30 |
| API (user data) | private, no-cache |
| Sensitive data | no-store |
→ Use the HTTP Status Codes reference to understand 304 Not Modified and other caching-related responses.