| name | caching-patterns |
| description | Caching strategy patterns: Cache-Aside, Write-Through, Write-Behind, TTL design, cache invalidation, Redis patterns, CDN caching, HTTP cache headers, and cache stampede prevention. Caching is both a performance and correctness problem. |
Caching Patterns Skill
When to Activate
- Database queries are slow or overloaded
- External API calls need to be rate-limited or throttled
- High read:write ratio on any data
- Static or semi-static content being served dynamically
- Setting up CDN or HTTP caching headers
- Cache invalidation bugs causing stale data
Pattern Selection
Read-heavy, simple data? → Cache-Aside (Lazy Loading)
Write-heavy, consistency needed? → Write-Through
High write volume, async OK? → Write-Behind (Write-Back)
Immutable or rarely changes? → Cache forever, invalidate on change
Complex aggregation? → Computed cache with explicit invalidation
Pattern 1: Cache-Aside (Lazy Loading)
The most common pattern. Application manages the cache explicitly.
async function getUser(userId: string): Promise<User> {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!user) throw new NotFoundError(`User ${userId} not found`);
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
async function invalidateUser(userId: string) {
await redis.del(`user:${userId}`);
}
Pros: Simple, only caches what's actually read, tolerates cold starts
Cons: Cache miss on first read (thundering herd risk), data can be stale up to TTL
Pattern 2: Write-Through
Cache and DB updated together on every write. Strong consistency.
async function updateUser(userId: string, data: Partial<User>): Promise<User> {
const [updated] = await db
.update(users)
.set(data)
.where(eq(users.id, userId))
.returning();
const cacheKey = `user:${userId}`;
await redis.setex(cacheKey, 3600, JSON.stringify(updated));
return updated;
}
Pros: Cache always consistent with DB
Cons: Every write hits both DB and cache (higher write latency), cache polluted with rarely-read data
Pattern 3: Write-Behind (Write-Back)
Write to cache immediately, persist to DB asynchronously. Highest write throughput.
async function incrementCounter(key: string, amount = 1) {
const newValue = await redis.incrby(`counter:${key}`, amount);
await queue.add('persist-counter', { key, value: newValue }, {
delay: 5000,
jobId: `counter:${key}`,
removeOnComplete: true,
});
return newValue;
}
queue.process('persist-counter', async (job) => {
const { key, value } = job.data;
await db.update(counters).set({ value }).where(eq(counters.key, key));
});
Pros: Extremely fast writes, batches DB load
Cons: Data loss risk if cache fails before persistence, complex failure handling
Cache Invalidation Strategies
await redis.setex(key, ttlSeconds, value);
async function invalidateOnWrite(entity: string, id: string) {
const patterns = [
`${entity}:${id}`,
`${entity}:list:*`,
`user:${userId}:${entity}s`,
];
await Promise.all(patterns.map(p => redis.del(p)));
}
const CACHE_VERSION = process.env.DEPLOY_SHA?.slice(0, 8) ?? 'v1';
function cacheKey(key: string) {
return `${CACHE_VERSION}:${key}`;
}
Cache Stampede Prevention
When a popular cache entry expires, thousands of concurrent requests hit the DB simultaneously.
async function getWithPER<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>,
beta = 1.0
): Promise<T> {
const raw = await redis.get(key);
if (raw) {
const { value, expiry } = JSON.parse(raw);
const remainingTtl = expiry - Date.now() / 1000;
if (remainingTtl > 0 && -beta * Math.log(Math.random()) < remainingTtl) {
return value;
}
}
const value = await fetchFn();
await redis.setex(key, ttl, JSON.stringify({
value,
expiry: Date.now() / 1000 + ttl,
}));
return value;
}
async getWithLock<T>(
: ,
: ,
: <T>,
ttl =
): <T> {
cached = redis.(key);
(cached) .(cached);
acquired = redis.(lockKey, , , , );
(!acquired) {
( (r, ));
(key, lockKey, fetchFn, ttl);
}
{
value = ();
redis.(key, ttl, .(value));
value;
} {
redis.(lockKey);
}
}
HTTP Cache Headers
function cacheControl(opts: {
maxAge?: number; // Browser cache (seconds)
sMaxAge?: number; // CDN cache (seconds)
staleWhileRevalidate?: number;
noStore?: boolean;
}) {
return (req: Request, res: Response, next: NextFunction) => {
if (opts.noStore) {
res.setHeader('Cache-Control', 'no-store');
} else {
const directives = [
'public',
opts.maxAge !== undefined && `max-age=${opts.maxAge}`,
opts.sMaxAge !== undefined && `s-maxage=${opts.sMaxAge}`,
opts.staleWhileRevalidate !== undefined &&
`stale-while-revalidate=${opts.staleWhileRevalidate}`,
].filter(Boolean);
res.setHeader('Cache-Control', directives.join(', '));
}
next();
};
}
app.get('/api/v1/products', cacheControl({ : , : }), handler);
app.(, ({ : }), handler);
app.(, ({ : , : }), express.());
TTL Design Guide
| Data type | Recommended TTL | Reason |
|---|
| User session | 15-30 min (sliding) | Security |
| User profile | 5-60 min | Rarely changes |
| Product catalog | 1-24 hours | Business-controlled updates |
| Search results | 5-15 min | Freshness vs. cost |
| Computed aggregates | 1-5 min | High compute cost |
| Static config | Until deploy | Version-invalidate on deploy |
| Rate limit counters | Match window (60s) | Functional requirement |
| Auth tokens | Token expiry | Must match exactly |
Checklist