Caching is the highest-leverage performance optimization in most systems — a single cache hit saves database queries, computation, and network round trips. The hard parts are invalidation (knowing when cached data is stale), stampede prevention (preventing thousands of requests to rebuild the same cache entry), and layering (which data belongs at which cache level). Claude Code generates the cache patterns appropriate for each data access pattern.
Cache-Aside Pattern
Cache user profiles. Read from cache first, then DB on miss.
Invalidate on profile update. Handle race conditions.
// services/UserCache.ts
import { Redis } from 'ioredis';
import { db } from '../db';
const redis = new Redis(process.env.REDIS_URL!);
const TTL = 3600; // 1 hour
export class UserCache {
private key(userId: string) { return `user:${userId}`; }
private lockKey(userId: string) { return `lock:user:${userId}`; }
async get(userId: string): Promise<User | null> {
const cached = await redis.get(this.key(userId));
if (cached) return JSON.parse(cached);
// Cache stampede prevention: distributed lock
const lockAcquired = await redis.set(
this.lockKey(userId), '1', 'NX', 'EX', 10
);
if (!lockAcquired) {
// Another process is fetching — wait briefly and retry from cache
await new Promise(r => setTimeout(r, 50));
const retried = await redis.get(this.key(userId));
return retried ? JSON.parse(retried) : null;
}
try {
const user = await db('users').where('id', userId).first();
if (user) {
await redis.setex(this.key(userId), TTL, JSON.stringify(user));
}
return user ?? null;
} finally {
await redis.del(this.lockKey(userId));
}
}
async invalidate(userId: string): Promise<void> {
await redis.del(this.key(userId));
}
async invalidateMany(userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
await redis.del(...userIds.map(id => this.key(id)));
}
}
Write-Through Pattern
Product inventory must always be fresh — cache and DB must stay in sync.
Write to both simultaneously on every update.
// services/InventoryCache.ts — write-through: always write cache + DB together
export class InventoryCache {
async updateStock(productId: string, newStock: number): Promise<void> {
// Write to both in parallel — if either fails, retry
await Promise.all([
db('products').where('id', productId).update({ stock: newStock }),
redis.setex(`inventory:${productId}`, 300, newStock.toString()), // 5 min TTL
]);
}
async getStock(productId: string): Promise<number> {
const cached = await redis.get(`inventory:${productId}`);
if (cached !== null) return parseInt(cached);
// Miss — load from DB and populate cache
const product = await db('products').where('id', productId).first('stock');
const stock = product?.stock ?? 0;
await redis.setex(`inventory:${productId}`, 300, stock.toString());
return stock;
}
}
Batch Caching with Multi-Get
We're making 50 individual cache requests per page render.
Batch them into one round trip.
// services/ProductCache.ts — batch fetch with multi-get
export class ProductCache {
async getMany(productIds: string[]): Promise<Map<string, Product>> {
if (productIds.length === 0) return new Map();
const keys = productIds.map(id => `product:${id}`);
const cached = await redis.mget(...keys);
const results = new Map<string, Product>();
const missedIds: string[] = [];
cached.forEach((value, index) => {
if (value !== null) {
results.set(productIds[index], JSON.parse(value));
} else {
missedIds.push(productIds[index]);
}
});
if (missedIds.length > 0) {
// Single DB query for all cache misses
const products = await db('products').whereIn('id', missedIds);
const pipeline = redis.pipeline();
for (const product of products) {
results.set(product.id, product);
pipeline.setex(`product:${product.id}`, 3600, JSON.stringify(product));
}
await pipeline.exec();
}
return results;
}
}
CDN Cache Configuration
Configure Cloudflare caching rules for our API.
Cache static assets forever. Cache API responses for 1 minute.
Never cache authenticated responses or checkout endpoints.
// Set cache headers in your API responses
export function setCacheHeaders(res: Response, options: {
maxAge?: number;
sMaxAge?: number; // CDN TTL (overrides maxAge for CDN)
private?: boolean;
noStore?: boolean;
staleWhileRevalidate?: number;
tags?: string[]; // Cache tags for targeted purging (Cloudflare Cache-Tag)
}) {
if (options.noStore) {
res.setHeader('Cache-Control', 'no-store');
return;
}
const directives: string[] = [];
if (options.private) {
directives.push('private');
} else {
directives.push('public');
}
if (options.maxAge !== undefined) {
directives.push(`max-age=${options.maxAge}`);
}
if (options.sMaxAge !== undefined) {
directives.push(`s-maxage=${options.sMaxAge}`);
}
if (options.staleWhileRevalidate !== undefined) {
directives.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
}
res.setHeader('Cache-Control', directives.join(', '));
// Cloudflare Cache-Tag for instant purging by tag
if (options.tags?.length) {
res.setHeader('Cache-Tag', options.tags.join(','));
}
}
// Route-level cache configuration
app.get('/api/products', async (req, res) => {
const products = await productCache.getMany(/* ... */);
setCacheHeaders(res, {
sMaxAge: 60, // CDN caches for 1 minute
maxAge: 0, // Browsers don't cache
staleWhileRevalidate: 300, // Serve stale for 5 min while revalidating
tags: ['products'], // Purge tag when products change
});
res.json(products);
});
app.get('/api/products/:id', async (req, res) => {
const product = await productCache.get(req.params.id);
setCacheHeaders(res, {
sMaxAge: 3600,
maxAge: 60,
tags: [`product:${req.params.id}`], // Purge single product
});
res.json(product);
});
// Never cache authenticated routes
app.get('/api/cart', authenticate, (req, res) => {
setCacheHeaders(res, { private: true, maxAge: 0 });
// ...
});
Cache Invalidation Strategy
// After product update, purge CDN cache for affected URLs/tags
async function onProductUpdated(productId: string) {
// 1. Invalidate application cache
await redis.del(`product:${productId}`);
// 2. Purge CDN cache by tag
await fetch(`https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/cache/tags`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tags: [`product:${productId}`, 'products'] }),
});
// 3. Rebuild cache proactively (optional: prevents stampede on popular products)
const product = await db('products').where('id', productId).first();
await redis.setex(`product:${productId}`, 3600, JSON.stringify(product));
}
CLAUDE.md for Caching Decisions
## Caching Strategy
- User profiles: cache-aside, 1h TTL, invalidate on update
- Product catalog: write-through + CDN, 1h TTL, tag-based purge
- Inventory: write-through, 5min TTL (always fresh)
- Search results: cache-aside, 5min TTL, key = hash(query+filters)
- Auth tokens: Redis, TTL = token expiry, no extension
- Never cache: checkout, payment, user-specific financial data
- Stampede prevention: distributed lock pattern for hot keys
- Cache miss rate target: < 10% for product catalog, < 20% for search
For the Redis setup and infrastructure these patterns require, see the Redis guide. For CDN configuration beyond caching including DDoS protection, see the Cloudflare Workers guide. The Claude Skills 360 bundle includes caching skill sets for Redis patterns, CDN configuration, and cache strategy selection. Start with the free tier to try cache implementation generation.