Design multi-tier caching architectures for web applications — cache-aside vs write-through vs write-behind, TTL design, cache invalidation, Redis patterns, CDN configuration, browser caching, and stampede prevention. Use when choosing a caching pattern, designing cache invalidation strategies, implementing Redis caching, configuring Cache-Control headers, or preventing cache stampedes. Activate on "cache invalidation", "cache-aside", "write-through", "TTL", "Redis cache", "CDN caching", "cache stampede", "stale data", "browser cache". NOT for database query caching within an ORM, memoization of pure functions, or CPU-level caching.
Design multi-tier caching architectures for web applications — cache-aside vs write-through vs write-behind, TTL design, cache invalidation, Redis patterns, CDN configuration, browser caching, and stampede prevention. Use when choosing a caching pattern, designing cache invalidation strategies, implementing Redis caching, configuring Cache-Control headers, or preventing cache stampedes. Activate on "cache invalidation", "cache-aside", "write-through", "TTL", "Redis cache", "CDN caching", "cache stampede", "stale data", "browser cache". NOT for database query caching within an ORM, memoization of pure functions, or CPU-level caching.
{"category":"DevOps & Site Reliability","tags":["caching","strategies","cache-invalidation","cache-aside","write-through"],"pairs-with":[{"skill":"react-performance-optimizer","reason":"Client-side caching (memoization, SWR) complements server-side cache architecture"},{"skill":"performance-profiling","reason":"Profiling identifies cache miss hotspots that caching strategies then address"},{"skill":"cloudflare-worker-dev","reason":"Edge caching with Cloudflare KV/Cache API is a key tier in multi-level cache architectures"}]}
Caching is the most commonly misapplied performance technique. The failure mode is not "cache too little" — it is "cache without an invalidation strategy and then discover the problem in production six months later when users complain about stale data that you cannot explain."
When to Use
✅ Use for:
Choosing which caching pattern fits a use case (cache-aside, write-through, write-behind)
Designing TTL values for different data freshness requirements
Rule: Data mutates in one place first. Invalidation flows outward: DB → Redis → CDN. Never skip tiers in invalidation.
Cache-Aside Pattern (Most Common)
Application manages cache explicitly. On read: check cache, if miss fetch from DB, populate cache, return. On write: update DB, delete cache entry.
classUserCache {
privateredis: Redis;
privatereadonlyTTL_SECONDS = 300; // 5 minutesasyncgetUser(userId: string): Promise<User> {
const key = `user:${userId}`;
// 1. Check cacheconst cached = awaitthis.redis.get(key);
if (cached) returnJSON.parse(cached);
// 2. Cache miss — fetch from sourceconst user = await db.users.findById(userId);
if (!user) thrownewNotFoundError('User', userId);
// 3. Populate cacheawaitthis.redis.setex(key, this.TTL_SECONDS, JSON.stringify(user));
return user;
}
asyncupdateUser(userId: string, data: Partial<User>): Promise<User> {
const user = await db.users.update(userId, data);
// 4. Invalidate — delete, don't update// Updating in cache risks race conditions; let the next read repopulateawaitthis.redis.del(`user:${userId}`);
return user;
}
}
When invalidation deletes vs overwrites: Delete is almost always correct. Overwriting in cache after a write creates a race: another request may have fetched the old value between your DB write and your cache write. Delete forces the next reader to fetch fresh.
Write-Through Pattern
Every write goes to cache and DB synchronously. Cache is always populated. Good for data that is written once and read many times.
asyncfunctioncreateProduct(data: CreateProductInput): Promise<Product> {
// Write to DB first (source of truth)const product = await db.products.create(data);
// Immediately populate cache — no future cache miss for this productconst key = `product:${product.id}`;
await redis.setex(key, 3600, JSON.stringify(product));
// Also invalidate list caches that include this productawait redis.del('products:list:*'); // pattern delete via SCAN, see redis-patterns.mdreturn product;
}
Trade-off: Higher write latency (two writes per operation). Wasted cache space for items that are never read again after creation. Best for data with high read:write ratio.
TTL Design
TTL is not a cache invalidation strategy — it is a staleness budget. Design TTLs based on data volatility and acceptable staleness:
Data Type
TTL
Rationale
User session token
Match session expiry
Security requirement
User profile (name, avatar)
5-15 minutes
Changes rarely; short enough for responsiveness
Product catalog
1-4 hours
Changes occasionally; acceptable lag
Inventory counts
30 seconds
Changes frequently; short but not zero
Exchange rates
60 seconds
Regulatory; must not be too stale
Static config / feature flags
60 seconds + pub/sub invalidation
Needs push invalidation on change
Computed aggregates (daily stats)
Until next computation
Explicit invalidation on recalculate
TTL jitter: When many keys have the same TTL, they expire simultaneously, causing a thundering herd. Add random jitter:
A stampede (also: dog-pile, thundering herd) occurs when many requests simultaneously miss an expired cache key and all rush to compute or fetch the value.
Strategy 1: Probabilistic Early Expiry (XFetch)
Re-fetch before expiry with probability proportional to how close the key is to expiring:
Only one worker recomputes the value; others wait on the lock or return stale data:
asyncfunction getWithLock<T>(
key: string,
fetcher: () =>Promise<T>,
ttl: number
): Promise<T> {
const cached = await redis.get(key);
if (cached) returnJSON.parse(cached);
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'PX', 5000); // 5s TTLif (!lockAcquired) {
// Another worker is computing — poll briefly then return stale or throwawaitsleep(100);
const retried = await redis.get(key);
if (retried) returnJSON.parse(retried);
thrownewError('Cache unavailable');
}
try {
const value = awaitfetcher();
await redis.setex(key, ttl, JSON.stringify(value));
return value;
} finally {
await redis.del(lockKey);
}
}
Consult references/redis-patterns.md for the Lua-atomic version of this lock (prevents lock release by wrong client).
Anti-Patterns
Anti-Pattern: Cache Everything Forever
Novice: "Caching makes things fast. Set TTL to 0 (no expiry) or a year to maximize cache hit rate."
Expert: Unbounded caches are memory leaks with extra steps. They also guarantee stale data — users see prices, permissions, and content from months ago. Production incidents traced to "why is this user seeing the old plan limit" are almost always cache-forever bugs.
// Wrong — no expiry means the cache grows foreverawait redis.set(`user:${id}`, JSON.stringify(user)); // no TTL// Right — every cache entry has a maximum lifetimeawait redis.setex(`user:${id}`, 300, JSON.stringify(user)); // 5 minutes
Python equivalent:
# Wrong
redis.set(f"user:{id}", json.dumps(user))
# Right
redis.setex(f"user:{id}", 300, json.dumps(user))
Detection: redis.set(key, value) without EX/PX/EXAT options. Redis TTL key returning -1 for cache keys. Memory growth over time with no plateau.
Timeline: This has always been wrong, but the Redis default of no-expiry makes it easy to do accidentally. Redis 7.0 (2022) introduced key eviction policies as default, reducing severity — but you still get stale data.
Anti-Pattern: No Invalidation Strategy
Novice: "I'll set a short TTL and the stale data problem solves itself."
Expert: TTL-only invalidation means every change to data has a propagation delay equal to the TTL. For some data (user roles, permissions, prices after a sale ends) that lag is unacceptable. Worse: this creates an implicit contract that is never documented, and teams later increase the TTL for performance without realizing they just made the staleness window much larger.
// Problem: user loses admin role, but can still access admin routes for 5 minutesawait redis.setex(`user:permissions:${id}`, 300, JSON.stringify(permissions));
// Right: invalidate explicitly on changeasyncfunctionrevokeAdminRole(userId: string) {
await db.userRoles.delete(userId, 'admin');
await redis.del(`user:permissions:${userId}`); // immediate invalidation// Also publish to notify other app instances to clear L1 cachesawait redis.publish('permissions:invalidated', userId);
}
LLM mistake: LLMs frequently omit invalidation logic in code generation because it is invisible in simple cache-aside examples. Every tutorial shows "set on write," few show "delete on update."
Detection: Cache sets with no corresponding deletes in write paths. TTL as the only eviction mechanism for user-controlled data (roles, permissions, settings). No DEL, UNLINK, or pub/sub events in the codebase's update handlers.