The Caching Pyramid
Effective caching operates at multiple layers. Each layer closer to the user provides faster responses but is typically smaller in capacity. A well-designed caching strategy uses every layer: browser cache, service worker cache, CDN/edge cache, application cache (in-memory/Redis), and database query cache.
Cache Layer Hierarchy
- Browser Cache (~0ms): HTTP cache headers, localStorage, IndexedDB. Fastest but per-user only.
- Service Worker Cache (~1ms): Programmable cache with offline support. Per-user, persistent.
- CDN/Edge Cache (~10-50ms): Shared across users. Global distribution. Cache-Control driven.
- Application Cache (~1-5ms): In-memory (LRU) or Redis. Shared across requests. Application-logic driven.
- Database Cache (~5-20ms): Query result cache, materialized views. Managed by the database.
HTTP Caching Strategy
// Comprehensive HTTP caching for different content types
// next.config.js headers configuration
module.exports = {
async headers() {
return [
// Immutable static assets (hashed filenames)
{
source: '/_next/static/:path*',
headers: [{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
}],
},
// Fonts — long cache with immutable
{
source: '/fonts/:path*',
headers: [{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
}],
},
// HTML pages — stale-while-revalidate pattern
{
source: '/((?!api).*)',
headers: [{
key: 'Cache-Control',
// Browser: no cache. CDN: 60s fresh, 5min stale-while-revalidate
value: 'public, max-age=0, s-maxage=60, stale-while-revalidate=300',
}],
},
// Cacheable API endpoints
{
source: '/api/products/:path*',
headers: [{
key: 'Cache-Control',
value: 'public, max-age=30, s-maxage=120, stale-while-revalidate=600',
}],
},
// Non-cacheable API endpoints
{
source: '/api/user/:path*',
headers: [{
key: 'Cache-Control',
value: 'private, no-cache, no-store',
}],
},
];
},
};Application-Level Caching
// In-memory LRU cache for Node.js applications
class LRUCache<T> {
private cache = new Map<string, { value: T; expiry: number }>();
private maxSize: number;
constructor(maxSize: number = 1000) {
this.maxSize = maxSize;
}
get(key: string): T | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiry) {
this.cache.delete(key);
return undefined;
}
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
set(key: string, value: T, ttlMs: number): void {
if (this.cache.size >= this.maxSize) {
// Delete least recently used (first item)
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(key, { value, expiry: Date.now() + ttlMs });
}
}
// Usage in API routes
const productCache = new LRUCache<any>(500);
async function getProduct(id: string) {
const cacheKey = `product:${id}`;
const cached = productCache.get(cacheKey);
if (cached) return cached;
const product = await prisma.product.findUnique({ where: { id } });
if (product) {
productCache.set(cacheKey, product, 5 * 60 * 1000); // 5 min TTL
}
return product;
}
// Stale-while-revalidate pattern at application level
class SWRCache<T> {
private cache = new Map<string, {
value: T;
staleAt: number;
expireAt: number;
revalidating: boolean;
}>();
async get(key: string, fetcher: () => Promise<T>, freshMs: number, staleMs: number): Promise<T> {
const entry = this.cache.get(key);
const now = Date.now();
// Cache miss or expired
if (!entry || now > entry.expireAt) {
const value = await fetcher();
this.cache.set(key, {
value,
staleAt: now + freshMs,
expireAt: now + freshMs + staleMs,
revalidating: false,
});
return value;
}
// Fresh — return cached
if (now <= entry.staleAt) return entry.value;
// Stale — return cached but revalidate in background
if (!entry.revalidating) {
entry.revalidating = true;
fetcher().then(value => {
this.cache.set(key, {
value,
staleAt: now + freshMs,
expireAt: now + freshMs + staleMs,
revalidating: false,
});
});
}
return entry.value;
}
}Cache Invalidation Patterns
// Cache invalidation strategies
// 1. Time-based expiration (TTL)
// Simplest but may serve stale data
await redis.set('product:123', JSON.stringify(product), 'EX', 3600);
// 2. Event-based invalidation
// Invalidate on write operations
async function updateProduct(id: string, data: any) {
const product = await prisma.product.update({ where: { id }, data });
// Invalidate specific cache entries
await redis.del(`product:${id}`);
await redis.del('products:listing');
await redis.del(`category:${product.categoryId}:products`);
// Purge CDN cache
await fetch(`https://api.cdn.com/purge/product-${id}`, { method: 'POST' });
// Revalidate ISR pages
await fetch(`/api/revalidate?path=/products/${product.slug}`);
return product;
}
// 3. Tag-based invalidation
// Group related cache entries with tags for bulk invalidation
class TaggedCache {
constructor(private redis: any) {}
async set(key: string, value: any, tags: string[], ttl: number) {
const pipeline = this.redis.pipeline();
pipeline.set(key, JSON.stringify(value), 'EX', ttl);
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
}
await pipeline.exec();
}
async invalidateTag(tag: string) {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await this.redis.del(...keys);
}
await this.redis.del(`tag:${tag}`);
}
}
// Usage
const cache = new TaggedCache(redis);
await cache.set('product:123', product, ['products', 'category:electronics'], 3600);
// Invalidate all electronics products at once:
await cache.invalidateTag('category:electronics');Caching Best Practices
- Cache at every layer: Browser, CDN, application, and database
- Use stale-while-revalidate: Serve stale data while refreshing in the background
- Tag cache entries: Enable targeted invalidation of related cache entries
- Set appropriate TTLs: Balance freshness requirements with cache hit rates
- Monitor hit rates: Track and optimize cache effectiveness at each layer