Caching: The Fastest Code Is Code That Doesn't Run
Caching is the most impactful performance optimization. A response served from cache is 10-1000x faster than one generated from scratch. But caching introduces complexity: stale data, cache invalidation, and consistency issues.
The Caching Layers
User → Browser Cache → CDN Edge → Server Cache (Redis) → Application → Database
~0ms ~20ms ~1ms ~50ms ~100msBrowser Cache (Cache-Control)
# Cache for 1 hour, then revalidate
Cache-Control: public, max-age=3600
# Cache for 1 year (immutable assets with hash in filename)
Cache-Control: public, max-age=31536000, immutable
# Don't cache at all
Cache-Control: no-store
# Cache but always revalidate with server
Cache-Control: no-cache
# (confusing name — it DOES cache, but always checks freshness)
# Private (only browser, not CDN)
Cache-Control: private, max-age=3600| Directive | Meaning |
|---|---|
public | Any cache can store this (browser, CDN, proxy) |
private | Only the browser can cache (not CDN) |
max-age=N | Cache for N seconds |
s-maxage=N | CDN cache duration (overrides max-age for shared caches) |
no-cache | Must revalidate before using cached version |
no-store | Never cache, ever |
stale-while-revalidate=N | Serve stale for N seconds while fetching fresh copy |
immutable | Content will never change (skip revalidation) |
What to Cache Where
| Content | Strategy | Cache-Control |
|---|---|---|
| Static assets (JS, CSS, images) | Long cache + content hash in filename | public, max-age=31536000, immutable |
| HTML pages (static) | CDN with revalidation | public, s-maxage=3600, stale-while-revalidate=600 |
| API responses (public) | Short CDN cache | public, s-maxage=60 |
| API responses (user-specific) | Private or no-store | private, no-cache |
| Auth pages | Never cache | no-store |
CDN Edge Caching
# Vercel/Next.js — ISR (Incremental Static Regeneration)
export const revalidate = 3600; // regenerate every hour
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return {/* render data */};
}
# Cloudflare Workers
return new Response(body, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=600',
},
});Server-Side Cache (Redis)
// Cache database queries
async function getProduct(id) {
const cacheKey = `product:${id}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache miss — query database
const product = await db.products.findById(id);
// Store in cache with TTL
await redis.set(cacheKey, JSON.stringify(product), 'EX', 300); // 5 min
return product;
}
// Invalidate on update
async function updateProduct(id, data) {
await db.products.update(id, data);
await redis.del(`product:${id}`); // bust the cache
}Cache Invalidation (The Hard Part)
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
- TTL-based: Cache expires after N seconds. Simple but may serve stale data.
- Event-based: Invalidate when data changes (database triggers, pub/sub).
- Versioned keys:
product:42:v3— bump version on update, old versions expire naturally. - Stale-while-revalidate: Serve stale data immediately, refresh in background.
Test your headers: HTTP Reference — verify cache headers and status codes.