HTTP Caching: Browser Cache, CDN, and Cache-Control Headers
Caching is one of the highest-ROI performance optimizations available to web developers. A well-cached response is served instantly, without hitting your server at all. This guide covers the HTTP caching model from browser cache to CDN to origin.
How HTTP Caching Works
When a browser or CDN receives a response, it checks the caching headers to determine:
- 1Can this response be cached?
- 2How long is it fresh?
- 3When it expires, can it be revalidated rather than re-fetched?
The primary header controlling this is Cache-Control.
Cache-Control Directives
Cache-Control: public, max-age=86400, stale-while-revalidate=60Key directives:
| Directive | Meaning |
|---|---|
public | Response can be cached by browsers and CDNs |
private | Response can only be cached by the browser (not CDN) |
no-cache | Cache is allowed, but must revalidate before use |
no-store | Never cache this response |
max-age=N | Cache for N seconds |
s-maxage=N | CDN-specific max age (overrides max-age for CDNs) |
stale-while-revalidate=N | Serve stale while revalidating in background |
immutable | Content will never change; skip revalidation |
Caching Static Assets
Fingerprinted assets (e.g., main.a3f7b1.js) can be cached forever — the filename changes on each build.
location ~* .(js|css|png|jpg|jpeg|gif|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}With fingerprinting: when the file changes, the URL changes, so cached versions are never stale.
Caching HTML
HTML should not be cached aggressively — it references your versioned assets:
location ~* .html$ {
add_header Cache-Control "no-cache, must-revalidate";
}no-cache doesn't mean "don't cache" — it means "cache, but always revalidate." The browser sends a conditional request, and if the content hasn't changed, the server returns 304 Not Modified with no body — fast and bandwidth-efficient.
ETag and Last-Modified (Conditional Requests)
These headers enable revalidation:
ETag: A hash of the content. Browser sendsIf-None-Matchon revalidation.Last-Modified: Timestamp. Browser sendsIf-Modified-Sinceon revalidation.
# First request
GET /index.html
← 200 OK
← ETag: "abc123"
← Last-Modified: Mon, 01 Jan 2025 00:00:00 GMT
# Revalidation request
GET /index.html
→ If-None-Match: "abc123"
← 304 Not Modified (no body, uses cached version)Node.js/Express sends ETags automatically. Nginx does too for static files.
CDN Caching
A CDN (Cloudflare, AWS CloudFront, Fastly) caches responses at edge nodes worldwide. The flow:
User → CDN Edge (cache hit) → Response
User → CDN Edge (cache miss) → Origin Server → CDN Edge (stores) → ResponseUse s-maxage to give CDNs a different TTL than browsers:
Cache-Control: public, max-age=60, s-maxage=3600Browser caches for 1 minute; CDN caches for 1 hour. Good for semi-dynamic content — users get fresh data, but your origin isn't hammered.
Vary Header
The Vary header tells caches to store separate versions of a response based on request headers:
Vary: Accept-EncodingThis tells caches to store separate copies for gzip and brotli clients. If you serve different content based on Accept-Language, add it to Vary.
Cache Busting Strategies
When you need to invalidate cached content:
- 1Fingerprinting — Change the filename on every build (webpack/Vite do this automatically).
- 2Query parameters —
/app.js?v=2(less reliable, some CDNs ignore query strings). - 3CDN purge API — Cloudflare, CloudFront, etc. offer API endpoints to purge specific URLs.
- 4Versioned paths —
/v2/app.js(robust but requires coordinated deploys).
Practical Cache Strategy
| Content Type | Cache-Control | Notes |
|---|---|---|
| Fingerprinted JS/CSS | public, max-age=31536000, immutable | Forever (URL changes on build) |
| Images (fingerprinted) | public, max-age=31536000, immutable | Same |
| HTML pages | no-cache | Always revalidate |
| API responses (public) | public, max-age=60, s-maxage=300 | Short cache, CDN layer |
| API responses (private) | private, max-age=30 | Browser only |
| Authenticated responses | no-store | Never cache |
Express.js Caching Headers
// Cache static assets aggressively
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}));
// API route with short cache
app.get('/api/public-data', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, s-maxage=300');
res.json(data);
});
// No caching for authenticated routes
app.get('/api/user', authenticate, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(user);
});Conclusion
HTTP caching is a layered system. Browser caches reduce repeat requests. CDNs reduce origin load. ETags enable efficient revalidation. Use aggressive caching for fingerprinted assets, conservative caching for HTML, and never cache sensitive authenticated responses. Deploy your optimally-cached app on PandaStack at [dashboard.pandastack.io](https://dashboard.pandastack.io) — see [docs.pandastack.io](https://docs.pandastack.io) for deployment options.