A modern, sleek data center with a row of illuminated server racks on the right and a bright, minimalist corridor leading to a lighted exit.
13 July 2026
12 min read

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:

  1. On a read miss: Who fetches data from the database - the application or the cache layer?

  2. 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.

  Clean enterprise-style infographic illustrating caching strategies in distributed systems. The layout is structured into vertical columns with soft blue-gray tones and rounded cards. Each section represents a caching pattern (Cache-Aside, Read-Through, Write-Through, Write-Around, Write-Back) and includes simplified flow diagrams showing interactions between application, cache (e.g., Redis), and database (e.g., Postgres). Arrows indicate read and write paths, including cache hits, misses, synchronous writes, and asynchronous flush operations. Supporting text highlights behavior, trade-offs, and typical use cases for each strategy. The overall design emphasizes clarity, system flow, and architectural decision-making in high-load environments. 

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

 

Minimalist enterprise-style infographic showing the Cache-Aside (Lazy Loading) pattern in a clean blue-gray layout. The diagram illustrates a step-by-step read flow: a client request enters the application, which first checks the cache. On a cache hit, data is returned immediately; on a miss, the application queries the database, stores the result in the cache, and then returns the response. A secondary write flow shows the application writing directly to the database and invalidating the cache entry to ensure fresh data on the next read. Arrows and labels clearly distinguish hit and miss paths, emphasizing system behavior under different scenarios. The composition highlights performance optimization, cache usage, and data consistency trade-offs in read-heavy systems. 

Pros:

  1. Simple to implement with Redis or Memcached
  2. Flexible - different data can have different TTLs
  3. If cache breaks, the system still works (just slower)

Cons:

  1. Application code owns cache logic (more code to maintain)
  2. First read for a key is slow (always hits database)
  3. 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

Minimalist enterprise-style infographic illustrating the Read-Through caching pattern. The diagram shows a clear data flow where a client request goes through the application to the cache layer, which acts as the single access point. On a cache hit, data is returned immediately. On a cache miss, the cache layer itself retrieves data from the database, stores it, and returns it to the application. Visual arrows distinguish hit and miss paths, emphasizing that the application never communicates directly with the database. The layout uses clean blue-gray tones, structured blocks for client, application, cache, and database, and highlights centralized loading logic, consistent read behavior, and shared cache usage across multiple services.

Pros:

  1. Application stays simple (no "check cache, then DB" logic)
  2. Scaling multiple services becomes easier (they all use same cache loader)
  3. Cache layer owns the data-fetching logic

Cons:

  1. Cache layer must know how to load from database
  2. If cache loader breaks, all reads break
  3. 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

 

Minimalist enterprise-style infographic illustrating the Write-Through caching pattern. The diagram shows a structured flow where a client request goes through the application to the cache layer, which acts as the single write entry point. The cache then synchronously writes the data to the database before confirming success back to the application. Arrows clearly depict the write path from application to cache to database, emphasizing that both layers are updated in a single operation. A secondary read-after-write flow highlights that subsequent reads are served directly from the cache with fresh data. The layout uses clean blue-gray tones, labeled components (client, application, cache, database), and visual indicators of synchronous operations, focusing on consistency, immediate data availability, and reliable read-after-write behavior in high-load systems. 

Pros:

  1. Cache and database always in sync (strong consistency)
  2. Reads right after writes always see fresh data
  3. Reduces manual cache invalidation

Cons:

  1. Writes are slower (must wait for both cache and database)
  2. All writers must go through the cache layer (or cache gets stale)
  3. 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

 

Minimalist enterprise-style infographic illustrating the Write-Around caching strategy. The diagram shows a clear separation between write and read paths. On writes, arrows flow directly from the application to the database, explicitly bypassing the cache layer, which is visually dimmed or marked as inactive. On reads, the flow follows a cache-aside pattern: the application checks the cache, returns data on a hit, or fetches from the database on a miss and then stores the result in the cache. The layout uses clean blue-gray tones with labeled components (client, application, cache, database) and distinct visual cues for “skip cache” and “fill on read.” The composition emphasizes efficient write handling, reduced cache pollution, and delayed cache population in write-heavy, low-read scenarios. 

Pros:

  1. Writes are fast (don't wait for cache)
  2. Prevents rarely-accessed data from polluting cache
  3. Simple write path

Cons:

  1. First read after write is slow (cache was cold)
  2. Existing cached data becomes stale until TTL expires
  3. 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

 

Minimalist enterprise-style infographic illustrating the Write-Back (Write-Behind) caching pattern. The diagram shows a write flow where a client request passes through the application to the cache layer, which immediately acknowledges success while storing data in memory. A separate asynchronous flow is depicted from the cache to the database, indicating delayed persistence handled in the background. Arrows and visual cues distinguish immediate write acknowledgment from deferred database updates. A read path highlights that subsequent reads are served from the cache, ensuring fast response times. The layout uses clean blue-gray tones with clearly labeled components (client, application, cache, database) and emphasizes high write throughput, reduced database load, and the trade-off of potential data loss if the cache fails before persistence completes. 

Pros:

  1. Extremely fast writes (cache ACKs immediately)
  2. Reduces database write load during spikes
  3. Background workers can batch writes (fewer database round-trips)

Cons:

  1. Risk of data loss: If cache crashes before background write, data is gone
  2. Complex failure handling (need write-ahead logs, queues, retries)
  3. 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 response

Real-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=3600

This 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=123

Benefit: 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:

  1. Consistency is non-obvious: What data depends on what? Which caches are affected?

  2. Silent failures: Stale cache doesn't crash; it silently serves wrong data.

  3. Scale: At millions of requests/second, a small invalidation bug affects millions of users.

  4. 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 experience

Solution: 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 issue

Solution: 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 50x

Solution: 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 minutes

Use 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 60

Ready to Scale? Book a 30-minute strategy session to discuss your caching challenges and get specific recommendations.

Schedule Your Free Consultation

Conclusion

Caching isn't magic. It's a series of carefully considered trade-offs between speed, consistency, and complexity.

Minimalist enterprise-style bar chart illustrating the impact of caching on application performance. The chart compares average response time before and after caching, with a tall red bar labeled “Before Caching” (around 280 ms) and a much shorter green bar labeled “After Caching” (around 30 ms). The layout uses clean blue-gray tones, subtle grid lines, and clear axis labeling for response time in milliseconds. The visual emphasizes a significant reduction in latency and highlights improved system responsiveness and reduced database load after implementing caching.

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.

 

FAQ

• contact • contact • contact • contact
• contact • contact • contact • contact