Caching patterns
Caching patterns are the backbone of performant systems, yet getting them wrong can turn a performance optimization into a consistency nightmare. Over my years building e-commerce platforms and real-time trading systems, I've learned that choosing the right caching pattern isn't about picking the fanciest one—it's about understanding the tradeoffs and your data's requirements. In this article, I'll walk you through four core caching patterns, when to use each one, and the operational gotchas that trip up most teams.
Comparison: Pattern vs. Behavior
Before diving into each pattern, let's establish a mental model. Every caching pattern differs in who is responsible for loading and writing data, and when it happens:
| Pattern | Responsibility | Behavior | Best for |
|---|---|---|---|
| Cache-aside | Application | App loads cache; miss → fetch from DB and populate cache | Read-heavy, complex queries, variable access patterns |
| Read-through | Cache library | Cache loads from DB automatically on miss | Uniform data model, transparent caching |
| Write-through | Application | Write to cache and DB together, wait for both | Strong consistency requirement, lower write volume |
| Write-behind | Cache layer | Write to cache immediately, async write to DB | High write volume, eventual consistency acceptable |
Notice the pattern: cache-aside and write-through put logic in your app, while read-through and write-behind delegate to the cache layer. This distinction matters for testing, debugging, and operational visibility.
Default to cache-aside
Cache-aside is my go-to pattern. It's simple, explicit, and gives your application full control. Here's how it works: your app always checks the cache first. On a miss, it loads from the database and populates the cache before returning to the caller. On a hit, you get the value directly from cache.
I've used cache-aside successfully across e-commerce product catalogs, CRM customer records, and trading order books. The pattern is straightforward to reason about and easy to test.
Here's a practical implementation in TypeScript:
class UserService {
constructor(
private db: Database,
private cache: Redis
) {}
async getUser(userId: string): Promise<User> {
// Try cache first
const cached = await this.cache.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Cache miss: fetch from DB
const user = await this.db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (!user) {
throw new NotFoundError(`User ${userId} not found`);
}
// Populate cache for next access
await this.cache.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
async updateUser(userId: string, updates: Partial<User>): Promise<User> {
// Write to DB first
const updated = await this.db.query(
'UPDATE users SET ? WHERE id = ? RETURNING *',
[updates, userId]
);
// Invalidate cache to prevent stale reads
await this.cache.del(`user:${userId}`);
return updated;
}
}
Why I prefer cache-aside:
- Explicit control: You decide what gets cached and for how long. No surprises.
- Resilient: If Redis crashes, your app still works—it just falls back to the database.
- Flexible: You can cache aggregated data, computed results, or transformed objects. Not limited to raw DB values.
- Observable: You see exactly when cache hits and misses happen. This visibility is invaluable for debugging.
The tradeoff is responsibility: your application code must handle cache logic. But that's actually a strength. It forces you to be intentional about what you cache.
Plan invalidation
Here's a hard truth I learned the expensive way: every cached value needs an expiry or invalidation rule. Without one, you'll ship stale data to production.
I've seen two main approaches, and they often work together:
1. Time-based expiry (TTL) is the simplest. You set a time-to-live when caching, and Redis automatically evicts the key. For user profiles, I typically use 1 hour. For product catalogs, 24 hours. For real-time trading data, 1 minute or less.
2. Event-based invalidation is more precise. When data changes, you explicitly delete the cache key. In a ride-hailing system, when a driver's location updates, you invalidate their cached location immediately. This ensures riders always see fresh data.
Here's how I combine both in a product service:
class ProductService {
constructor(
private db: Database,
private cache: Redis,
private events: EventBus
) {
// Listen for product updates from other services
this.events.on('product.updated', (productId) => {
this.invalidateProduct(productId);
});
}
async getProduct(productId: string): Promise<Product> {
const cacheKey = `product:${productId}`;
const cached = await this.cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const product = await this.db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
);
// 6-hour TTL for normal cases
// Event-based invalidation handles urgent updates
await this.cache.setex(cacheKey, 21600, JSON.stringify(product));
return product;
}
async updateProduct(productId: string, updates: Partial<Product>): Promise<Product> {
const updated = await this.db.query(
'UPDATE products SET ? WHERE id = ? RETURNING *',
[updates, productId]
);
// Explicit invalidation for immediate consistency
await this.invalidateProduct(productId);
// Notify other services
await this.events.emit('product.updated', productId);
return updated;
}
private async invalidateProduct(productId: string): Promise<void> {
const cacheKey = `product:${productId}`;
await this.cache.del(cacheKey);
}
}
The strategy here: use a reasonable TTL as a safety net, but rely on event-based invalidation for changes you control. This gives you strong consistency for important data while reducing cache churn.
Prevent stampedes
A cache stampede happens when many requests hit an expired key simultaneously. They all miss, all query the database together, and your database spikes. In a high-traffic scenario—say, a flash sale on an e-commerce platform—this can cascade into an outage.
I've dealt with this in two ways:
1. Request coalescing (deduplication) ensures only the first request queries the database. Subsequent requests wait for that result and share it. Here's an implementation:
class CoalescingCache {
private pendingRequests = new Map<string, Promise<any>>();
async get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
// Check if another request is already fetching this key
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}
// Start a new fetch and track it
const promise = fetcher().then((value) => {
this.pendingRequests.delete(key);
return value;
}).catch((error) => {
this.pendingRequests.delete(key);
throw error;
});
this.pendingRequests.set(key, promise);
return promise;
}
}
// Usage in your service
class ProductService {
private coalescingCache = new CoalescingCache();
async getProduct(productId: string): Promise<Product> {
return this.coalescingCache.get(
`product:${productId}`,
async () => {
// Only this function runs once, even if 100 requests come in
return this.db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
);
}
);
}
}
2. Distributed locks use Redis to coordinate across instances. When a key expires, the first instance to acquire the lock fetches fresh data. Others wait:
async function getProductWithLock(productId: string): Promise<Product> {
const cacheKey = `product:${productId}`;
const lockKey = `lock:${cacheKey}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Try to acquire lock (expires in 5 seconds to avoid deadlock)
const lockAcquired = await redis.set(lockKey, 'locked', 'EX', 5, 'NX');
if (lockAcquired) {
// We got the lock: fetch from DB
const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
await redis.setex(cacheKey, 3600, JSON.stringify(product));
await redis.del(lockKey);
return product;
} else {
// Someone else has the lock: wait and retry
await new Promise(r => setTimeout(r, 100));
return getProductWithLock(productId);
}
}
Coalescing is simpler for single-instance services. Locks are better for distributed systems where requests land on different servers. I typically combine both: coalesce within an instance, use locks across instances.
Common mistakes
1. Caching everything: Just because you can cache doesn't mean you should. Cache what matters: high-volume reads, expensive queries, or data that changes infrequently. Caching user preferences that change every request is waste.
2. Forgetting invalidation: The most common mistake. You cache user data and forget to invalidate after an update. Users see stale profiles for hours. Always plan invalidation upfront.
3. Mixing patterns carelessly: I've seen teams use cache-aside for reads but write-behind for writes. This creates inconsistency. Pick a pattern family and stick with it within a service.
4. Not monitoring cache hit rates: If your cache hit rate is below 70% for a hot dataset, something's wrong. Maybe your TTL is too short, or your access pattern doesn't match the cache design.
5. Ignoring serialization overhead: JSON serialization/deserialization adds latency. For high-volume caches, consider MessagePack or Protobuf. I've saved milliseconds per request this way in trading systems.
Key takeaways
- Cache-aside is your default. It's simple, resilient, and gives you full control. Use it unless you have a specific reason not to.
- Every cached value must expire or be invalidated. There's no such thing as "fire and forget" caching. Plan invalidation from day one.
- Prevent cache stampedes with coalescing or locks. A small amount of upfront complexity saves you from production incidents.
- Monitor cache behavior. Track hit rates, evictions, and TTL effectiveness. Caching is a performance multiplier only if it actually works.
- Consistency is a choice, not an accident. Cache-aside + event-based invalidation gives strong consistency. Write-behind gives throughput. Know what your system needs.






Comments
Loading comments…