In distributed systems design, data access speeds vary wildly: CPU registers operate in nanoseconds, RAM access takes ~100 nanoseconds, while disk-backed database queries and network hops take tens or hundreds of milliseconds. Caching is the primary architectural mechanism used to bridge this immense speed disparity.

However, introducing a cache layer brings significant architectural complexity. As Phil Karlton famously observed: "There are only two hard things in Computer Science: cache invalidation and naming things." In this comprehensive system design guide, we analyze the 4 core caching patterns, eviction strategies, and strategies to defend against catastrophic cache stampedes.

1. The Four Primary Caching Patterns

1. Cache-Aside (Lazy Loading)

The application coordinates both the cache and database. When data is requested, the app inspects the cache first. If a cache miss occurs, the app reads from the database, writes the result to the cache, and returns it to the client.

Python (Cache-Aside with Distributed Lock)
async def get_article_data(article_id: str):
    cache_key = f"article:{article_id}"
    
    # 1. Attempt cache retrieval
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)
        
    # 2. Cache miss: Fetch from primary relational DB
    article = await postgres_db.fetch_one("SELECT * FROM articles WHERE id = $1", article_id)
    if not article:
        # Cache negative response for 60s to prevent Cache Penetration
        await redis_client.set(cache_key, json.dumps(None), ex=60)
        return None

    # 3. Populate cache with TTL (1 hour)
    await redis_client.set(cache_key, json.dumps(dict(article)), ex=3600)
    return article

2. Write-Through Caching

The application writes data to the cache, and the cache synchronously writes to the database before returning a success acknowledgment. Ensures the cache is always 100% consistent with the database, but adds write latency.

3. Write-Behind (Write-Back) Caching

The application writes immediately to the in-memory cache and acknowledges success. An asynchronous background worker batches updates and persists them to disk. Delivers massive write throughput (used in heavy logging, analytics, and gaming leaderboards), but risks data loss if the cache node crashes before flushing.

4. Refresh-Ahead Caching

The cache engine automatically reloads frequently accessed keys from the database before their TTL expires, ensuring hot keys never experience latency penalties on user queries.

2. Cache Eviction Policies

Because in-memory RAM is significantly more expensive than disk storage, caches operate with fixed memory allocations. When memory exhausts, the engine must evict items:

3. The Three Great Caching Failure Modes

Failure Mode What Happens Architectural Defense
Cache Penetration Adversary queries non-existent IDs repeatedly, forcing 100% of requests to hit database. Bloom Filters in front of cache, or caching empty null sentinels with short TTL.
Cache Stampede (Breakdown) A hot key expires, causing 10,000 concurrent threads to run the expensive DB query at once. Mutex Locks (only 1 thread recomputes) or Probabilistic Early Expiration (XFetch).
Cache Avalanche Thousands of keys share the exact same TTL and expire at the identical second, crashing the DB. TTL Jitter: Add random noise (e.g., baseTTL + random(0, 300s)) to stagger expirations.

Frequently Asked Questions (FAQ)

Q: How do you choose between Redis and Memcached?

Memcached is a simple multithreaded key-value store suitable for straightforward string caching. Redis is far more versatile: it provides complex data structures (Lists, Sets, Hashes, Sorted Sets, Bitmaps), persistence options (RDB/AOF), Pub/Sub messaging, and Lua script transactions, making it the overwhelming industry standard.

Q: What is a Bloom Filter?

A Bloom Filter is a space-efficient probabilistic data structure that tests whether an element is a member of a set. It can definitively tell you if an item does not exist (zero false negatives), intercepting invalid queries before they ever touch the database.

Conclusion

Caching is the most cost-effective lever to scale modern distributed systems. By selecting the appropriate caching pattern, configuring LRU/LFU eviction policies, and applying TTL jitter, you protect your core database while maintaining sub-millisecond API response times.

💡 Engineering Key Takeaway

Apply TTL jitter to prevent cache avalanches and combine Cache-Aside with distributed mutexes to eliminate stampedes on high-traffic keys.

Production Redis Distributed Mutex (Locking) Script

Prevent cache stampedes under heavy traffic with atomic Redis operations:

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

export async function fetchWithMutexLock(
  key: string,
  fetcher: () => Promise,
  ttlSeconds = 600
): Promise {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, 'locked', 'EX', 5, 'NX');

  if (acquired) {
    try {
      const freshData = await fetcher();
      await redis.set(key, JSON.stringify(freshData), 'EX', ttlSeconds);
      return freshData;
    } finally {
      await redis.del(lockKey);
    }
  }

  // Wait 80ms and retry reading warm cache
  await new Promise(r => setTimeout(r, 80));
  return fetchWithMutexLock(key, fetcher, ttlSeconds);
}
SK

Written by Sajid Khan

Principal Software Engineer & Author

Sajid is a full-stack engineer and tech writer passionate about web performance, resilient backend architectures, and developer mentorship. He authors in-depth tutorials on modern JavaScript, React, and systems engineering.