Caching Strategies
Cache-Aside (Lazy Loading) — Most Common
async function getUser(id: string): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
if (user) {
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600); // 1h TTL
}
return user;
}
// Invalidate on update
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await db.user.update({ where: { id }, data });
await redis.del(`user:${id}`);
return user;
}
Write-Through
async function updateProduct(id: string, data: UpdateDto): Promise<Product> {
const product = await db.product.update({ where: { id }, data });
await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);
return product;
}
HTTP Caching
// Express middleware
app.get('/api/products', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
'ETag': generateETag(products),
});
res.json(products);
});
// Conditional requests
app.get('/api/products/:id', (req, res) => {
const product = getProduct(req.params.id);
const etag = generateETag(product);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({ ETag: etag, 'Cache-Control': 'private, max-age=0, must-revalidate' });
res.json(product);
});
Cache-Control Cheat Sheet
| Directive |
Use Case |
public, max-age=3600 |
Static assets, CDN-cacheable |
private, max-age=60 |
User-specific data |
no-cache |
Always revalidate (ETag/Last-Modified) |
no-store |
Sensitive data (banking, health) |
stale-while-revalidate=300 |
Serve stale, refresh in background |
Redis Caching Patterns
// Hash for structured data
await redis.hset(`user:${id}`, { name, email, plan });
const user = await redis.hgetall(`user:${id}`);
// Sorted set for leaderboards
await redis.zadd('leaderboard', score, `user:${id}`);
const top10 = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES');
// Cache with refresh-ahead
async function getWithRefresh<T>(key: string, ttl: number, fetcher: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
if (cached) {
const { data, expiresAt } = JSON.parse(cached);
// Refresh in background if nearing expiry
if (Date.now() > expiresAt - ttl * 200) {
fetcher().then((fresh) =>
redis.set(key, JSON.stringify({ data: fresh, expiresAt: Date.now() + ttl * 1000 }), 'EX', ttl)
);
}
return data;
}
const data = await fetcher();
await redis.set(key, JSON.stringify({ data, expiresAt: Date.now() + ttl * 1000 }), 'EX', ttl);
return data;
}
Invalidation Strategies
| Strategy |
Description |
| TTL-based |
Set expiration, tolerate staleness |
| Event-driven |
Invalidate on write events |
| Tag-based |
Group keys by tag, purge by tag |
| Versioned keys |
user:v2:${id} — change version to invalidate all |
Anti-Patterns
| Anti-Pattern |
Fix |
| No TTL on cache entries |
Always set TTL to prevent stale data |
| Cache stampede (many misses at once) |
Use locking or stale-while-revalidate |
| Caching mutable data without invalidation |
Invalidate on writes or use short TTL |
| Caching everything |
Cache hot data only; measure hit rates |
| Serializing large objects |
Cache only needed fields |
Production Checklist
1---2name: caching-strategies3description: Application caching patterns. Redis caching, in-memory caches, HTTP caching, cache invalidation strategies, cache-aside, write-through, and CDN caching. USE WHEN: user mentions "caching", "cache invalidation", "Redis cache", "HTTP cache", "CDN caching", "cache-aside", "write-through", "TTL", "stale-while-revalidate" DO NOT USE FOR: Redis as database/queue - use `redis` or `job-queues`; browser storage - use frontend skills4---5# Caching Strategies67## Cache-Aside (Lazy Loading) — Most Common89```typescript10async function getUser(id: string): Promise<User> {11 const cached = await redis.get(`user:${id}`);12 if (cached) return JSON.parse(cached);1314 const user = await db.user.findUnique({ where: { id } });15 if (user) {16 await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600); // 1h TTL17 }18 return user;19}2021// Invalidate on update22async function updateUser(id: string, data: UpdateUserDto): Promise<User> {23 const user = await db.user.update({ where: { id }, data });24 await redis.del(`user:${id}`);25 return user;26}27```2829## Write-Through3031```typescript32async function updateProduct(id: string, data: UpdateDto): Promise<Product> {33 const product = await db.product.update({ where: { id }, data });34 await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 3600);35 return product;36}37```3839## HTTP Caching4041```typescript42// Express middleware43app.get('/api/products', (req, res) => {44 res.set({45 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',46 'ETag': generateETag(products),47 });48 res.json(products);49});5051// Conditional requests52app.get('/api/products/:id', (req, res) => {53 const product = getProduct(req.params.id);54 const etag = generateETag(product);5556 if (req.headers['if-none-match'] === etag) {57 return res.status(304).end();58 }5960 res.set({ ETag: etag, 'Cache-Control': 'private, max-age=0, must-revalidate' });61 res.json(product);62});63```6465### Cache-Control Cheat Sheet6667| Directive | Use Case |68|-----------|----------|69| `public, max-age=3600` | Static assets, CDN-cacheable |70| `private, max-age=60` | User-specific data |71| `no-cache` | Always revalidate (ETag/Last-Modified) |72| `no-store` | Sensitive data (banking, health) |73| `stale-while-revalidate=300` | Serve stale, refresh in background |7475## Redis Caching Patterns7677```typescript78// Hash for structured data79await redis.hset(`user:${id}`, { name, email, plan });80const user = await redis.hgetall(`user:${id}`);8182// Sorted set for leaderboards83await redis.zadd('leaderboard', score, `user:${id}`);84const top10 = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES');8586// Cache with refresh-ahead87async function getWithRefresh<T>(key: string, ttl: number, fetcher: () => Promise<T>): Promise<T> {88 const cached = await redis.get(key);89 if (cached) {90 const { data, expiresAt } = JSON.parse(cached);91 // Refresh in background if nearing expiry92 if (Date.now() > expiresAt - ttl * 200) {93 fetcher().then((fresh) =>94 redis.set(key, JSON.stringify({ data: fresh, expiresAt: Date.now() + ttl * 1000 }), 'EX', ttl)95 );96 }97 return data;98 }99 const data = await fetcher();100 await redis.set(key, JSON.stringify({ data, expiresAt: Date.now() + ttl * 1000 }), 'EX', ttl);101 return data;102}103```104105## Invalidation Strategies106107| Strategy | Description |108|----------|-------------|109| TTL-based | Set expiration, tolerate staleness |110| Event-driven | Invalidate on write events |111| Tag-based | Group keys by tag, purge by tag |112| Versioned keys | `user:v2:${id}` — change version to invalidate all |113114## Anti-Patterns115116| Anti-Pattern | Fix |117|--------------|-----|118| No TTL on cache entries | Always set TTL to prevent stale data |119| Cache stampede (many misses at once) | Use locking or stale-while-revalidate |120| Caching mutable data without invalidation | Invalidate on writes or use short TTL |121| Caching everything | Cache hot data only; measure hit rates |122| Serializing large objects | Cache only needed fields |123124## Production Checklist125126- [ ] TTL set on all cache entries127- [ ] Cache invalidation on data mutations128- [ ] Hit rate monitoring (aim for >90%)129- [ ] Memory limits configured on Redis130- [ ] Cache key naming convention documented131- [ ] Graceful fallback when cache unavailable