)
Caching Strategies That Actually Work in High-Load Systems
Caching is often treated as an afterthought. Teams add Redis when performance becomes a crisis, choose a strategy by accident, and then spend months fighting inconsistent data or cache stampedes.
But caching isn't optional in high-load systems. It's a structural requirement. When a single database query takes 50ms and you need to handle 10,000 requests per second, you need something between the request and the database.
The problem isn't adding a cache - it's choosing the right strategy for your workload, then executing cache invalidation without chaos.
This article covers the five core caching strategies, how Redis and CDNs implement them, real-world trade-offs, and most critically: the cache invalidation patterns that clients actually use in production.
The Five Core Caching Strategies. Understand the Fundamental Trade-Off
Every caching strategy answers two questions:
On a read miss: Who fetches data from the database - the application or the cache layer?
On a write: What gets updated first, and when does the caller know the write succeeded?
Those answers decide if your system feels fast, if cached data stays fresh, and what happens when things break.
Strategy 1: Cache-Aside (Lazy Loading)
How it works:
The application checks the cache first. On a miss, it fetches from the database, stores the result, and returns it. On writes, the app writes to the database and deletes the cache entry so it loads fresh next time.
Client Request
↓
App checks cache → HIT? Return immediately
↓
App checks cache → MISS?
↓
App queries database
↓
App stores result in cache
↓
Return to client
Pros:
- Simple to implement with Redis or Memcached
- Flexible - different data can have different TTLs
- If cache breaks, the system still works (just slower)
Cons:
- Application code owns cache logic (more code to maintain)
- First read for a key is slow (always hits database)
- Stale data possible if you forget to invalidate
Real-world fit: Read-heavy workloads where occasional staleness is acceptable. Perfect for product catalogs, user profiles, configuration data.
Example: Reddit's feed. Most users see cached posts; only a fresh request loads new data from database.
Strategy 2: Read-Through
How it works:
The application only talks to the cache. If data is missing, the cache layer itself fetches from the database, stores it, and returns it.
Client Request
↓
App queries cache only
↓
Cache handles miss → fetches from DB → stores → returns
↓
Return to client
Pros:
- Application stays simple (no "check cache, then DB" logic)
- Scaling multiple services becomes easier (they all use same cache loader)
- Cache layer owns the data-fetching logic
Cons:
- Cache layer must know how to load from database
- If cache loader breaks, all reads break
- Cold reads still pay full database cost
Real-world fit: Shared read patterns across many services. Useful when Caffeine or similar local cache libraries are in play.
Example: Netflix's title cache. All services read titles through the same cache layer; if a title is missing, the cache itself loads it.
Strategy 3: Write-Through
How it works:
Every write updates both cache and database at the same time. The application writes to the cache layer, which synchronously writes to the database before returning success.
Client writes data
↓
App writes to cache
↓
Cache synchronously writes to database
↓
Both report success simultaneously
↓
Next read finds fresh data in cache
Pros:
- Cache and database always in sync (strong consistency)
- Reads right after writes always see fresh data
- Reduces manual cache invalidation
Cons:
- Writes are slower (must wait for both cache and database)
- All writers must go through the cache layer (or cache gets stale)
- Higher latency on write-heavy workloads
Real-world fit: Systems where reads immediately follow writes and consistency matters. Financial transactions, inventory systems, user account updates.
Example: Amazon inventory. When you buy a product, the cache updates immediately to reflect stock. Next user sees fresh inventory without cache delay.
Strategy 4: Write-Around
How it works:
Writes go directly to the database and skip the cache. The cache only fills on reads (using cache-aside).
Client writes data
↓
App writes directly to database (cache is skipped)
↓
Database stores data
↓
Next read: app checks cache → MISS → loads from DB → stores in cache
Pros:
- Writes are fast (don't wait for cache)
- Prevents rarely-accessed data from polluting cache
- Simple write path
Cons:
- First read after write is slow (cache was cold)
- Existing cached data becomes stale until TTL expires
- Need manual invalidation if data is already cached
Real-world fit: Write-heavy workloads where written data is rarely read immediately. Logging, analytics, bulk imports, event ingestion.
Example: User activity logs. Millions of events are written per second; most are never read. Why cache them?
Strategy 5: Write-Back (Write-Behind)
How it works:
Writes go to the cache first, and the database write happens asynchronously in the background. The application gets success immediately.
Client writes data
↓
App writes to cache (instant success)
↓
Cache queues database write in background
↓
Return to client immediately (data hasn't reached DB yet)
↓
Background worker flushes cache to DB later
Pros:
- Extremely fast writes (cache ACKs immediately)
- Reduces database write load during spikes
- Background workers can batch writes (fewer database round-trips)
Cons:
- Risk of data loss: If cache crashes before background write, data is gone
- Complex failure handling (need write-ahead logs, queues, retries)
- Ordering and duplicate writes must be managed
Real-world fit: High-volume, low-risk data where a small loss is acceptable. Counters, view counts, metrics, leaderboards, session state.
Example: Twitch view counts. Millions of viewers watching simultaneously. Cache absorbs writes; database updates every few seconds. If cache fails, some view counts are lost, but the cost is acceptable.
Redis Patterns for High-Load Systems
Redis as a Cache Layer
Redis is the industry standard for caching at scale. But Redis isn't just a key-value store—its data structures enable sophisticated caching patterns.
Pattern 1: Simple Cache-Aside with Redis
This is the most common pattern. Your application controls caching logic.
// Read path
async function getUser(userId) {
// Check Redis first
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Cache miss—fetch from database
const user = await database.query(`SELECT * FROM users WHERE id = ?`, [userId]);
// Store in cache for 1 hour
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
return user;
}
// Write path
async function updateUser(userId, updates) {
// Update database first
const user = await database.update(`users`, updates, { id: userId });
// Invalidate cache (force fresh load on next read)
await redis.del(`user:${userId}`);
return user;
}When to use: Read-heavy workloads, flexible caching rules, teams comfortable with cache logic in application code.
Pattern 2: Redis Sorted Sets for Leaderboards and Rankings
Redis sorted sets maintain data sorted by score automatically. Perfect for top-K queries.
Real scenario: Your system needs to show top-selling products without expensive database queries.
javascript
// Record a sale
async function recordSale(productId) {
// Increment product score in sorted set
await redis.zincrby('top_products', 1, `product:${productId}`);
}
// Get top 10 products
async function getTopProducts(limit = 10) {
// Fetch top N by score (descending)
const topIds = await redis.zrevrange('top_products', 0, limit - 1);
// Fetch full product data from cache or database
return Promise.all(topIds.map(id => getProduct(id)));
}Benefit: No complex SQL queries. Redis handles sorting in memory at massive scale.
Pattern 3: Redis Bloom Filters for Existence Checks
Before expensive database lookups, check if data might exist using minimal memory.
Real scenario: Check if a product ID exists before hitting the database.
async function productExists(productId) {
// Bloom filter tells us "definitely doesn't exist" or "might exist"
const mightExist = await redis.bf.exists('product_ids', productId);
if (!mightExist) {
// Definitely doesn't exist
return false;
}
// Might exist—check database
const product = await database.query(
`SELECT id FROM products WHERE id = ?`,
[productId]
);
return !!product;
}
// When a new product is added
async function addProduct(productId, data) {
await database.insert('products', data);
// Add to Bloom filter
await redis.bf.add('product_ids', productId);
}Benefit: Avoids thousands of unnecessary database queries per second.
Pattern 4: Redis Pub/Sub for Cache Invalidation
When data changes, notify all subscribers to invalidate their caches.
// Service A: Updates user data
async function updateUser(userId, updates) {
await database.update('users', updates, { id: userId });
// Broadcast invalidation event
await redis.publish('cache_invalidation', JSON.stringify({
type: 'user_updated',
userId: userId
}));
}
// Service B, C, D: Subscribe to invalidation events
redis.subscribe('cache_invalidation', async (message) => {
const event = JSON.parse(message);
if (event.type === 'user_updated') {
// Invalidate local cache
await redis.del(`user:${event.userId}`);
}
});Benefit: Decouples services. Any service can publish invalidation; others react automatically.
CDN and Edge Caching. CDN Caching for Global Systems
Redis handles database-level caching. CDNs handle content caching across the globe.
CDNs cache responses at edge nodes (data centers near users). When a request comes in, it's served from the nearest edge, not your origin server.
How CDN Caching Works
User in London → Nearest CDN edge in London
(cache hit) → instant response
User in Tokyo → Nearest CDN edge in Tokyo
(cache hit) → instant response
User in São Paulo → No cached content?
→ Origin server fetches
→ Returns through CDN
→ Caches at São Paulo edge
→ User gets responseReal-World Example: Shopify Product Pages
When a product page is viewed, the CDN caches the HTML response. Next 1000 views from the same region are served from edge cache—origin server never sees them.
Cache headers control this:
Cache-Control: public, max-age=3600This tells the CDN: "Cache this for 1 hour. Anyone can serve it from cache."
CDN Cache Invalidation
The challenge: How do you invalidate a cached page across all edges globally?
Option 1: TTL (Time To Live)
The cache expires after max-age. Simple but can result in stale content.
Option 2: Manual Purge
Cloudflare, AWS CloudFront, Fastly all support manual cache purge.
// When a product is updated
async function updateProduct(productId, changes) {
await database.update('products', changes, { id: productId });
// Purge CDN cache for this product page
await cdnClient.purgeCache({
urls: [`/product/${productId}`]
});
}Option 3: Surrogate Keys (Tag-Based Invalidation)
Instead of purging individual URLs, purge by tag. Multiple URLs can share a tag.
Response Headers:
Surrogate-Key: product-123 product-category-electronics
// Later, when product 123 is updated:
await cdnClient.purgeByTag('product-123');
// This invalidates ALL URLs tagged with 'product-123'
// Including /product/123, /category/electronics, /search?q=123Benefit: One invalidation request clears multiple pages.
The Cache Invalidation Problem (Real Client Pain)
Why Cache Invalidation is Hard
Phil Karlton famously said: "There are only two hard things in Computer Science: cache invalidation and naming things."
Here's why:
Consistency is non-obvious: What data depends on what? Which caches are affected?
Silent failures: Stale cache doesn't crash; it silently serves wrong data.
Scale: At millions of requests/second, a small invalidation bug affects millions of users.
Timing: Invalidate too early, you lose performance. Too late, users see stale data.
Real Client Scenarios
Scenario 1: Inventory Goes Stale (E-commerce)
User A buys last item in stock
↓
Database updated: quantity = 0
↓
Redis cache still says: quantity = 5 (expires in 30 minutes)
↓
User B sees product available, tries to buy
↓
Checkout fails: "Out of stock" (server-side catch)
↓
Bad user experienceSolution: Event-Driven Invalidation
// When stock changes
async function recordPurchase(productId, quantity) {
await database.updateStock(productId, -quantity);
// Immediately invalidate cache
await redis.del(`product:${productId}:inventory`);
// Broadcast to all services
await redis.publish('inventory_changed', JSON.stringify({
productId,
newQuantity: result.quantity
}));
}
// All services receive the event and invalidate
subscriberService.on('inventory_changed', (event) => {
redisLocal.del(`product:${event.productId}:inventory`);
});Scenario 2: User Settings Cached for Hours
Admin changes user permissions
↓
Database updated immediately
↓
Redis cache says: user can access premium (expires in 4 hours)
↓
User accesses premium content they should no longer have access to
↓
Security issueSolution: TTL-Based + Selective Invalidation
async function updateUserPermissions(userId, permissions) {
await database.update('users_permissions', permissions, { userId });
// Immediate invalidation
await redis.del(`user:${userId}:permissions`);
// Publish event for session invalidation
await redis.publish('permissions_changed', userId);
}
// Force session refresh
subscriberService.on('permissions_changed', (userId) => {
// Invalidate all user sessions
await redis.del(`session:${userId}:*`);
});Scenario 3: Cache Stampede (Thundering Herd)
Cache key expires at 12:00:00
↓
1000 requests arrive at 12:00:01
↓
All cache misses simultaneously
↓
All 1000 requests hit database at once
↓
Database overload, response times spike 50xSolution: Refresh-Ahead (Proactive Refresh)
Instead of waiting for cache to expire, refresh it before expiration:
async function scheduleCacheRefresh(key, ttl, loader) {
// Refresh cache 10% before expiration
const refreshAt = (ttl * 0.9) * 1000; // milliseconds
setTimeout(async () => {
const freshData = await loader();
await redis.setex(key, ttl, JSON.stringify(freshData));
// Schedule next refresh
scheduleCacheRefresh(key, ttl, loader);
}, refreshAt);
}
// Usage
scheduleCacheRefresh(
'top_products',
3600,
() => database.query('SELECT * FROM products ORDER BY sales DESC LIMIT 100')
);Benefit: Cache never expires. Users always see fresh data. Database load is predictable.
Cache Invalidation Strategies for Production
Strategy 1: TTL-Based (Simple, Imperfect)
Set an expiration time and let cache age out naturally.
Pros: Simple to implement
Cons: Users see stale data until TTL expires
await redis.setex('product:123', 300, JSON.stringify(data)); // 5 minutesUse when: Data staleness of 5-10 minutes is acceptable.
Strategy 2: Event-Driven Invalidation (Recommended)
When data changes, publish an event. Services listening to the event invalidate their caches.
Pros: Immediate consistency, decoupled services
Cons: Requires pub/sub setup, more moving parts
// Data updates
database.on('user_updated', async (userId) => {
await redis.publish('invalidate', JSON.stringify({
key: `user:${userId}`,
timestamp: Date.now()
}));
});
// Services listen
redis.subscribe('invalidate', async (message) => {
const { key } = JSON.parse(message);
await redis.del(key);
});Use when: Consistency is critical, updates are frequent.
Strategy 3: Stale-While-Revalidate (Balance Speed and Freshness)
Serve stale data immediately while refreshing in the background.
async function getProduct(productId) {
const cached = await redis.get(`product:${productId}`);
if (cached) {
// Check if stale
const age = await redis.ttl(`product:${productId}`);
if (age < 60) { // Expires in less than 60 seconds
// Refresh in background
refreshProductInBackground(productId);
}
return JSON.parse(cached); // Return stale data immediately
}
// Cache miss—fetch and store
const product = await database.get(productId);
await redis.setex(`product:${productId}`, 3600, JSON.stringify(product));
return product;
}
async function refreshProductInBackground(productId) {
const freshProduct = await database.get(productId);
await redis.setex(`product:${productId}`, 3600, JSON.stringify(freshProduct));
}Benefit: Users never experience slow requests. Data is refreshed before cache fully expires.
Use when: You need both speed and reasonable freshness.
Strategy 4: Selective Invalidation (Targeted, Not Broad)
Invalidate only specific cache entries, not the entire cache.
// Bad: Invalidates all product caches
await redis.del('product:*');
// Good: Invalidate only affected product and related indexes
async function updateProduct(productId, changes) {
await database.update('products', changes, { id: productId });
// Invalidate only this product
await redis.del(`product:${productId}`);
// Invalidate product category index
const category = await database.query('SELECT category FROM products WHERE id = ?', [productId]);
await redis.del(`category:${category}:products`);
// Invalidate search indexes if price changed
if (changes.price) {
await redis.del(`search:price_asc`);
await redis.del(`search:price_desc`);
}
}Benefit: Minimizes performance impact. You only clear what's necessary.
Real-World Implementation Guide
Choosing Your Strategy: Decision Tree
Is data consistency critical?
→ YES → Write-Through (slower writes, strong consistency)
→ NO → Continue...
Are writes much more frequent than reads?
→ YES → Write-Around or Write-Back
→ NO → Cache-Aside or Read-Through
Do you need extremely fast writes?
→ YES → Write-Back (with safety queue)
→ NO → Continue...
Is data rarely read immediately after being written?
→ YES → Write-Around
→ NO → Write-Through or Cache-Aside
Implementation Checklist
Define cache key strategy - Use consistent naming (e.g.,
entity:id:variant)Set appropriate TTLs - Balance freshness vs. load (typically 300-3600 seconds)
Plan invalidation - Event-driven, TTL, or stale-while-revalidate?
Handle cache misses - Prevent cache stampede with refresh-ahead
Monitor cache hit rates - Track performance (target >80% for most workloads)
Test failure scenarios - What happens if Redis crashes?
Document cache dependencies - Which endpoints depend on which cache keys?
Common Mistakes to Avoid
Mistake 1: Forgetting to Invalidate
// ❌ Bad
async function updateProduct(id, changes) {
await database.update('products', changes, { id });
// Forgot to invalidate cache!
}
// ✅ Good
async function updateProduct(id, changes) {
await database.update('products', changes, { id });
await redis.del(`product:${id}`);
}
Mistake 2: Caching Sensitive Data
// ❌ Bad - User passwords, tokens, or PII should NEVER be cached
await redis.set(`user:${id}:password`, hashedPassword);
// ✅ Good - Cache only safe data
await redis.set(`user:${id}:profile`, JSON.stringify({ name, email, avatar }));
Mistake 3: No TTL on Cache Keys
// ❌ Bad - Keys live forever
await redis.set(`user:${id}`, data);
// ✅ Good - Keys expire, preventing memory bloat
await redis.setex(`user:${id}`, 3600, data);
Mistake 4: Stampeding on Thundering Herd
// ❌ Bad - All requests hit DB simultaneously when cache expires
if (!cachedData) {
cachedData = await database.query();
redis.set(key, cachedData);
}
// ✅ Good - Use refresh-ahead to prevent stampede
if (!cachedData) {
cachedData = await database.query();
redis.setex(key, 3600, cachedData);
}
scheduleRefreshBefore(key, 3300); // Refresh at 55 minutes, not 60Ready to Scale? Book a 30-minute strategy session to discuss your caching challenges and get specific recommendations.
Schedule Your Free ConsultationConclusion
Caching isn't magic. It's a series of carefully considered trade-offs between speed, consistency, and complexity.

The five strategies - cache-aside, read-through, write-through, write-around, and write-back - each make different bets:
Cache-Aside: Simple, flexible, popular (but application manages complexity).
Read-Through: Clean reads, shared logic, but cache layer must know the database.
Write-Through: Strong consistency, but slower writes.
Write-Around: Fast writes, but first reads are slow.
Write-Back: Ultra-fast writes, but risky data loss.
For high-load systems, Redis handles database-level caching while CDNs cache content globally. Together, they can reduce origin server load by 95%+.
The hardest part isn't adding caching - it's invalidating it correctly. TTL-based expiration is simple but risks staleness. Event-driven invalidation ensures consistency but requires infrastructure. Stale-while-revalidate balances both.
Choose your strategy based on your workload's read/write pattern and tolerance for stale data. Implement it consistently. Monitor cache hit rates. And always test failure scenarios.
Caching at scale is a discipline. Master it, and your system can handle 10x load with the same database.
