HTTP Caching Headers: Cache-Control Explained
TL;DR
Cache-Control headers tell browsers and CDNs how long to store responses. Use max-age for static assets, no-cache for HTML, private for user data, immutable for versioned files. ETags enable conditional requests that skip downloading unchanged content.
I added two lines to our Nginx config and cut API server load by 60%. No code changes, no new infrastructure—just telling browsers and CDNs what they could cache and for how long. HTTP caching is the highest-leverage performance improvement most apps aren't fully using.
How Caching Works
Without caching:
Browser → Request → Server → Database → Response (200ms)
Browser → Request → Server → Database → Response (200ms)
Browser → Request → Server → Database → Response (200ms)
With caching:
Browser → Request → Server → Database → Response (200ms)
Browser → Cache hit → Response (0ms)
Browser → Cache hit → Response (0ms)
The browser (or CDN) stores the response. Subsequent requests never reach your server.
Cache-Control Directives
Cache-Control: max-age=3600, public
max-age=N — Cache for N seconds
Cache-Control: max-age=86400 # Cache for 1 day
Cache-Control: max-age=0 # Don't cache
public — CDNs and shared caches can store this
Cache-Control: public, max-age=3600 # CDN can cache
private — Only the browser can cache, not CDNs
Cache-Control: private, max-age=3600 # User-specific data
no-cache — Revalidate every time (can still use cached copy if unchanged)
Cache-Control: no-cache # Check with server before using cache
no-store — Never store this response
Cache-Control: no-store # Sensitive data, never cache
immutable — Content will never change, skip revalidation entirely
Cache-Control: public, max-age=31536000, immutable # Versioned static files
Static Assets: Cache Aggressively
# BAD - no cache headers, every page reload re-downloads assets
location /static/ {
root /app/public;
}
# GOOD - long cache with versioned filenames
location /static/ {
root /app/public;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
The trick: use content hashes in filenames. When the file changes, the filename changes, so the old cache is never used:
/static/app.js ← BAD: changing content doesn't bust cache
/static/app.abc123.js ← GOOD: new hash = new URL = fresh download
Most build tools do this automatically:
// webpack.config.js
module.exports = {
output: {
filename: '[name].[contenthash].js', // app.abc123.js
}
};
// vite.config.js - content hashes enabled by default in production
HTML: Cache Short or Not at All
# HTML pages: short or no cache
# You want users to always get the latest version
# Option 1: Always revalidate (recommended)
Cache-Control: no-cache
# Option 2: Very short cache
Cache-Control: public, max-age=60
# Option 3: CDN caches, browser doesn't
Cache-Control: public, s-maxage=3600, max-age=0
API Responses: It Depends
// Express - set cache headers based on data sensitivity
// BAD - no cache headers on any API response
app.get('/api/products', async (req, res) => {
const products = await getProducts();
res.json(products);
});
// GOOD - public data CDN can cache
app.get('/api/products', async (req, res) => {
const products = await getProducts();
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
res.json(products);
});
// User-specific data: only this browser caches it
app.get('/api/user/profile', authenticate, async (req, res) => {
const profile = await getUserProfile(req.user.id);
res.set('Cache-Control', 'private, max-age=300');
res.json(profile);
});
// Real-time or sensitive data: never cache
app.get('/api/prices/live', async (req, res) => {
const prices = await getLivePrices();
res.set('Cache-Control', 'no-store');
res.json(prices);
});
ETags: Smart Revalidation
ETag lets the browser check "has this changed?" without re-downloading:
First request:
Client → GET /api/posts/123
Server ← 200 OK
ETag: "abc123"
Body: { title: "My Post", ... } (full response)
Second request:
Client → GET /api/posts/123
If-None-Match: "abc123"
Server ← 304 Not Modified # Unchanged: no body, browser uses cache
OR 200 OK # Changed: full response with new ETag
ETag: "def456"
const crypto = require('crypto');
app.get('/api/posts/:id', async (req, res) => {
const post = await getPost(req.params.id);
// Generate ETag from content hash
const etag = '"' + crypto
.createHash('md5')
.update(JSON.stringify(post))
.digest('hex') + '"';
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'no-cache'); // Revalidate, but use cache if unchanged
res.json(post);
});
// Express enables ETags by default for res.json/res.send
app.set('etag', 'strong');
Last-Modified: Time-Based Revalidation
app.get('/api/posts/:id', async (req, res) => {
const post = await getPost(req.params.id);
const lastModified = post.updatedAt.toUTCString();
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince && new Date(ifModifiedSince) >= post.updatedAt) {
return res.status(304).end();
}
res.set('Last-Modified', lastModified);
res.set('Cache-Control', 'no-cache');
res.json(post);
});
CDN Caching: s-maxage
CDNs (Cloudflare, Fastly, CloudFront) have their own cache separate from the browser:
# Different cache times for browser vs CDN
Cache-Control: public, max-age=60, s-maxage=3600
# Browser: 1 minute
# CDN: 1 hour
// Product page: short browser cache, long CDN cache
// When data changes, purge CDN cache via API
res.set('Cache-Control', 'public, max-age=60, s-maxage=3600');
// User dashboard: never let CDN cache
res.set('Cache-Control', 'private, max-age=300');
Vary Header: Cache Per Variant
# Store different cached versions based on request headers
Vary: Accept-Encoding # Compressed vs uncompressed
Vary: Accept-Language # English vs French
app.get('/api/data', (req, res) => {
res.set('Vary', 'Accept-Encoding');
res.set('Cache-Control', 'public, max-age=3600');
if (req.headers['accept-encoding']?.includes('gzip')) {
res.set('Content-Encoding', 'gzip');
res.send(compressedData);
} else {
res.send(rawData);
}
});
Quick Reference
Static assets (versioned filenames)
Cache-Control: public, max-age=31536000, immutable
HTML pages
Cache-Control: no-cache
API - public data
Cache-Control: public, max-age=300
API - user-specific data
Cache-Control: private, max-age=300
API - real-time or sensitive
Cache-Control: no-store
CDN edge caching
Cache-Control: public, max-age=60, s-maxage=3600
The Bottom Line
HTTP caching is free performance. Two headers on your static file server eliminates most asset requests. Proper API cache headers cut database load and reduce latency for every user.
Key points:
- Use
immutable+ longmax-agefor versioned static files - Use
no-cachefor HTML so browsers check for updates - Use
privatefor user-specific data so CDNs don't mix users - ETags let browsers verify cached content is still fresh
s-maxagecontrols CDN cache separately from browser cache
Check your response headers right now with curl -I https://yoursite.com/. If you don't see Cache-Control, you're leaving performance on the table.