Caching Strategies
Caching eliminates repeated work. A well-designed caching strategy means returning visitors load instantly, CDN edges serve content without hitting your origin, and your database handles a fraction of the requests it otherwise would.
This guide covers the three layers of web caching: browser, CDN, and server-side.
Browser Caching
Cache-Control Header
The Cache-Control header tells browsers how to cache responses:
Cache-Control: max-age=31536000, immutable
Common directives:
| Directive | Meaning |
|---|---|
max-age=N |
Cache for N seconds |
no-cache |
Cache, but revalidate every time |
no-store |
Never cache (sensitive data) |
private |
Only browser can cache (not CDN) |
public |
Both browser and CDN can cache |
immutable |
Never revalidate (for fingerprinted assets) |
must-revalidate |
Once stale, must revalidate |
stale-while-revalidate=N |
Serve stale for N seconds while fetching fresh |
Caching Strategies by Resource Type
Fingerprinted assets (CSS, JS, images with hash in filename):
Cache-Control: public, max-age=31536000, immutable
Files like app.8f3d2a.js never change—the filename changes when content changes. Cache forever.
HTML pages (content changes):
Cache-Control: public, max-age=0, must-revalidate
Or for pages that update infrequently:
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
This serves cached content for 1 hour, then for the next 24 hours serves stale while fetching fresh in the background.
API responses (dynamic data):
Cache-Control: private, max-age=60
For user-specific data. Never cache on CDN.
Sensitive data:
Cache-Control: no-store
Auth tokens, personal info, etc. Don't cache anywhere.
Configuring Cache Headers
Nginx:
# Fingerprinted assets (immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
if ($uri ~* "\.[a-f0-9]{8,}\.") {
expires max;
add_header Cache-Control "public, immutable";
}
}
# Regular static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public";
}
# HTML
location ~* \.html$ {
expires 0;
add_header Cache-Control "no-cache, must-revalidate";
}
Express.js:
import express from 'express';
const app = express();
// Fingerprinted assets
app.use(
'/assets',
express.static('dist/assets', {
maxAge: '1y',
immutable: true,
})
);
// Regular static files
app.use(
express.static('public', {
maxAge: '1d',
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
}
},
})
);
Astro (via astro.config.mjs with adapter):
// Headers set at CDN/server level, not in Astro config
// See your deployment platform's documentation
ETag Validation
ETags allow the browser to ask "has this changed?" without re-downloading:
# Initial response
HTTP/1.1 200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Content-Length: 12345
# Subsequent request
GET /page.html HTTP/1.1
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
# If unchanged
HTTP/1.1 304 Not Modified
The 304 response is tiny—no body is sent. This is useful for frequently-changing content where you want validation but minimal transfer.
Most servers generate ETags automatically. For static files, it's typically based on file modification time and size.
Cache Busting
When content changes, you need to bust the cache. Two approaches:
Fingerprinted filenames (recommended):
<!-- Hash changes when content changes -->
<script src="/app.8f3d2a1b.js"></script>
<link rel="stylesheet" href="/styles.c4f2e9.css">
Bundlers like Vite generate these automatically:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
});
Query strings (works but less elegant):
<script src="/app.js?v=1.2.3"></script>
Some CDNs ignore query strings by default, making this less reliable.
CDN Caching
CDNs cache content at edge locations worldwide, reducing latency and origin load.
How CDN Caching Works
User → Edge (Dallas) → Origin (Virginia)
↓
[Cache Miss: Fetch from origin, store]
↓
[Cache Hit: Serve directly, skip origin]
Subsequent requests from Dallas-area users hit the edge cache, never reaching your origin.
CDN Cache Headers
CDNs respect Cache-Control headers, but often support additional directives:
Cloudflare:
# Control CDN separately from browser
Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=3600
Vercel:
# Stale-while-revalidate at edge
Cache-Control: public, max-age=60, stale-while-revalidate=3600
Vercel-CDN-Cache-Control: max-age=3600
AWS CloudFront:
# Uses standard Cache-Control
# Configure behaviors in CloudFront console
Surrogate Keys for Selective Purging
When content changes, you need to invalidate specific cached items:
# Tag responses with surrogate keys
Surrogate-Key: article-123 category-tech homepage
Then purge by key:
# Fastly API
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/article-123" \
-H "Fastly-Key: $API_KEY"
This invalidates all cached responses tagged with article-123 without affecting other content.
Edge Caching Patterns
Cache HTML at the edge:
Cache-Control: public, max-age=60, stale-while-revalidate=86400
For 1 minute, serve the cached version. For the next 24 hours, serve stale while fetching fresh. After that, block until fresh content arrives.
Bypass cache for authenticated users:
// Cloudflare Worker
export default {
async fetch(request) {
// Skip cache for authenticated requests
if (request.headers.get('Authorization')) {
return fetch(request, { cf: { cacheEverything: false } });
}
// Cache public content
return fetch(request, {
cf: {
cacheEverything: true,
cacheTtl: 3600,
},
});
},
};
Vary header for conditional caching:
# Cache different versions based on request headers
Vary: Accept-Encoding, Accept-Language
This tells the CDN to maintain separate cached versions for different Accept-Language values (for example).
Gotcha: Vary: * or Vary: Cookie often disables caching entirely.
Cache Invalidation
Path-based purging:
# Cloudflare
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $API_TOKEN" \
-d '{"files": ["https://example.com/page.html"]}'
# Vercel
vercel --scope=team purge https://example.com/page.html
Purge everything (nuclear option):
# Cloudflare
curl -X POST ".../purge_cache" -d '{"purge_everything": true}'
Use sparingly—this creates a thundering herd on your origin.
Soft purge (Fastly):
# Mark as stale but still serve while fetching fresh
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/all" \
-H "Fastly-Soft-Purge: 1"
Server-Side Caching
Caching at the application level prevents repeated computation and database queries.
In-Memory Caching
For single-server deployments or request-scoped data:
// Simple in-memory cache with TTL
class MemoryCache<T> {
private cache = new Map<string, { value: T; expires: number }>();
get(key: string): T | undefined {
const item = this.cache.get(key);
if (!item) return undefined;
if (Date.now() > item.expires) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
set(key: string, value: T, ttlMs: number): void {
this.cache.set(key, {
value,
expires: Date.now() + ttlMs,
});
}
}
const cache = new MemoryCache<string>();
async function getExpensiveData(id: string): Promise<Data> {
const cached = cache.get(`data:${id}`);
if (cached) return JSON.parse(cached);
const data = await fetchFromDatabase(id);
cache.set(`data:${id}`, JSON.stringify(data), 60_000); // 1 minute
return data;
}
Gotcha: In-memory caches don't share across processes/servers. Use Redis for distributed caching.
Redis Caching
Redis provides distributed, persistent caching across multiple servers:
// lib/cache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export async function cached<T>(
key: string,
fn: () => Promise<T>,
ttlSeconds = 300
): Promise<T> {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Compute and cache
const result = await fn();
await redis.setex(key, ttlSeconds, JSON.stringify(result));
return result;
}
// Usage
const user = await cached(
`user:${userId}`,
() => db.users.findUnique({ where: { id: userId } }),
600 // 10 minutes
);
Cache invalidation patterns:
// Invalidate on update
async function updateUser(id: string, data: Partial<User>) {
await db.users.update({ where: { id }, data });
await redis.del(`user:${id}`);
}
// Invalidate by pattern (use with caution)
async function invalidateUserCaches(userId: string) {
const keys = await redis.keys(`user:${userId}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
}
See the Redis article for detailed Redis usage patterns.
Full Page Caching
Cache entire HTML responses for anonymous users:
// middleware/pageCache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export async function pageCacheMiddleware(req, res, next) {
// Skip for authenticated users
if (req.user) {
return next();
}
const cacheKey = `page:${req.url}`;
const cached = await redis.get(cacheKey);
if (cached) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Content-Type', 'text/html');
return res.send(cached);
}
// Capture the response
const originalSend = res.send.bind(res);
res.send = (body) => {
if (res.statusCode === 200 && typeof body === 'string') {
redis.setex(cacheKey, 300, body); // 5 minutes
}
res.setHeader('X-Cache', 'MISS');
return originalSend(body);
};
next();
}
ISR: Incremental Static Regeneration
Next.js and similar frameworks can revalidate static pages on-demand:
// pages/posts/[slug].tsx (Next.js Pages Router)
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return {
props: { post },
revalidate: 60, // Regenerate every 60 seconds
};
}
export async function getStaticPaths() {
const posts = await getAllPosts();
return {
paths: posts.map((p) => ({ params: { slug: p.slug } })),
fallback: 'blocking', // Generate missing pages on-demand
};
}
On-demand revalidation (when content changes):
// pages/api/revalidate.ts
export default async function handler(req, res) {
const { slug, secret } = req.query;
if (secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
try {
await res.revalidate(`/posts/${slug}`);
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).send('Error revalidating');
}
}
Data Caching Patterns
Read-through cache:
async function getProduct(id: string): Promise<Product> {
const cacheKey = `product:${id}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss: fetch and store
const product = await db.products.findUnique({ where: { id } });
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
}
Write-through cache:
async function updateProduct(id: string, data: Partial<Product>) {
// Update database first
const product = await db.products.update({
where: { id },
data,
});
// Then update cache
await redis.setex(`product:${id}`, 3600, JSON.stringify(product));
return product;
}
Cache-aside (lazy loading):
async function getProducts(categoryId: string): Promise<Product[]> {
const cacheKey = `products:category:${categoryId}`;
// Try cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Fallback to database
const products = await db.products.findMany({
where: { categoryId },
});
// Background cache update (don't await)
redis.setex(cacheKey, 300, JSON.stringify(products)).catch(console.error);
return products;
}
Cache Warming
Pre-populate caches to avoid cold-start latency:
// scripts/warm-cache.ts
async function warmCache() {
const popularPaths = [
'/',
'/products',
'/about',
...await getTopProducts(100).then(ps => ps.map(p => `/products/${p.slug}`)),
];
for (const path of popularPaths) {
await fetch(`https://example.com${path}`);
console.log(`Warmed: ${path}`);
}
}
Run after deployments or cache purges.
Debugging Cache Issues
Check Cache Headers
# View response headers
curl -I https://example.com/page.html
# Check CDN cache status
curl -I https://example.com/page.html | grep -i "x-cache\|cf-cache-status\|age"
Common CDN status headers:
| Header | Values |
|---|---|
X-Cache |
HIT, MISS, EXPIRED |
CF-Cache-Status (Cloudflare) |
HIT, MISS, EXPIRED, DYNAMIC, BYPASS |
Age |
Seconds since cached |
X-Served-By |
Which edge served the request |
Cache Debugging Checklist
- Is the Cache-Control header correct? Check your server config
- Is Vary too broad?
Vary: Cookieoften disables caching - Are query strings being cached? Some CDNs ignore them by default
- Is the CDN respecting headers? Some CDNs have their own caching rules
- Is there a Set-Cookie header? This often prevents caching
Common Pitfalls
Set-Cookie preventing caching:
# Response with Set-Cookie is typically not cached
HTTP/1.1 200 OK
Set-Cookie: session=abc123
Cache-Control: public, max-age=3600
Solution: Set cookies on a different path, or use JavaScript for non-essential cookies.
Vary: Accept-Encoding issues:
# This is fine - most CDNs handle it
Vary: Accept-Encoding
# This is problematic - creates many cache variants
Vary: Accept-Encoding, User-Agent
Private content cached publicly:
// ❌ Dangerous: user data cached at CDN
res.setHeader('Cache-Control', 'public, max-age=3600');
res.json({ user: req.user });
// ✅ Safe: private cache only
res.setHeader('Cache-Control', 'private, max-age=60');
res.json({ user: req.user });
See Also
- Core Web Vitals - How caching affects TTFB and LCP
- Asset Optimization - Cache headers for static assets
- Redis - Detailed Redis caching patterns
- Docker Deployment - Nginx caching configuration
- MDN Cache-Control - Complete header reference