⚡ Database EngineeringUpdated: September 2, 2026

High-Throughput Redis Caching: Cache-Aside, Write-Through & Cache Invalidation Strategies

By Moemecto Distributed Systems & Software Architecture Group

Designing resilient caching tiers: cache stampede protection, probabilistic early expiration (XFetch), TTL jitter, and multi-tier L1 memory / L2 Redis architectures.

In high-concurrency enterprise web platforms, primary relational (PostgreSQL, MySQL) and document (MongoDB) databases inevitably experience performance degradation under heavy read volumes. Implementing an in-memory caching tier powered by Redis or Dragonfly is the standard mechanism to achieve sub-millisecond read latencies and protect persistent storage from saturation. However, deploying a distributed cache introduces profound operational complexities—including cache invalidation consistency, race conditions, cache stampedes (thundering herds), and memory eviction management.

⚙️ The Fundamental Cache Fallacy & Two Hard Problems

As Phil Karlton famously noted, cache invalidation is one of the two hardest problems in computer science. Operating a cache without deterministic invalidation boundaries results in dirty reads, data corruption, and silent financial reconciliation failures. Production architectures must treat the cache as a transient performance accelerator rather than a primary source of truth, enforcing strict Time-To-Live (TTL) expiration alongside explicit transactional invalidation events.

1. Core Caching Topologies: Cache-Aside vs. Write-Through vs. Write-Behind

Selecting the optimal caching topology depends on the system's tolerance for write latency, data consistency requirements, and read-to-write ratios:

Caching Strategy Write Path Flow Read Path Flow Consistency Guarantee Primary Failure Mode
Cache-Aside (Lazy Loading) Write to DB → Invalidate/Delete cache key Read cache → On miss, read DB → Populate cache Eventual Consistency Cache Stampede upon key expiration
Write-Through Write to Cache → Synchronously write to DB Read from Cache (Always warm) Strong Consistency High write latency overhead
Write-Behind (Write-Back) Write to Cache → Async queue → Batch DB write Read from Cache Eventual (Risk of data loss) Process crash before DB flush causes lost updates
Refresh-Ahead Normal write path Background worker refreshes hot keys before TTL High Consistency for predictable access Wasted computation on stale/cold items

2. Cache Failure Modes: Penetration, Breakdown, and Avalanche

Distributed caches are susceptible to three critical pathological access patterns that can instantly cascade into full infrastructure outages:

  • Cache Penetration (Queries for Non-Existent Data): Malicious attackers or buggy clients repeatedly query keys that do not exist in either the cache or the database (e.g., GET /users/invalid-id-999999). Every request bypasses the cache and hits the database. Remediation: Cache empty/null results with a short TTL (e.g., 60 seconds), or place a Bloom Filter in front of the cache to mathematically reject non-existent keys before touching persistent storage.
  • Cache Breakdown (The Hot Key Expiration Stampede): A single key that receives tens of thousands of requests per second (e.g., a viral news article or flash-sale product) suddenly reaches its TTL expiration. Thousands of concurrent requests encounter a cache miss simultaneously and attempt to recompute the data from the database, crushing connection pools. Remediation: Distributed locking (mutex) on cache misses, or Probabilistic Early Expiration (XFetch algorithm).
  • Cache Avalanche (Simultaneous Bulk Expiration): Thousands of keys written at the same time share an identical TTL (e.g., 24 hours). When they expire simultaneously, an avalanche of queries floods the database. Remediation: Apply TTL Jitter by adding a uniform pseudo-random offset: TTL = base_ttl + random(-jitter, +jitter).

3. Probabilistic Early Expiration: The XFetch Algorithm

Rather than waiting for a hot key to completely expire and risking a stampede, the XFetch algorithm (formally proven by Vattani et al.) recalculates cached values probabilistically before expiration. As the key approaches its expiration time, incoming read requests evaluate the probability of refreshing the key based on the compute delta:

should_refresh = -delta * beta * ln(random()) > (expiry - current_time)

Where delta is the computational duration required to generate the value from the database, beta is a aggressiveness constant ($> 0$, typically $1.0$), and random() is a uniform float between $0$ and $1$. As remaining TTL decreases, the probability of a background refresh approaches $100%$, guaranteeing that exactly one concurrent request asynchronously warms the cache without blocking other readers.

4. Redis Memory Eviction Policies in Production

When Redis reaches its allocated maxmemory threshold, it must discard keys to accommodate new writes. Selecting the incorrect policy can result in OOM crashes or eviction of critical session state:

  • volatile-lru: Evicts the least recently used keys among those with an explicit TTL set. Preserves static or persistent keys.
  • allkeys-lru: Evicts the least recently used keys across the entire database. Recommended for general-purpose cache instances where all data can be regenerated.
  • volatile-lfu / allkeys-lfu: Least Frequently Used eviction. Tracks access frequency via logarithmic counters, protecting hot items from being evicted by sudden temporary spikes of cold scans.
  • noeviction: Returns an error (OOM command not allowed) when memory limits are reached. Mandatory for Redis instances utilized as job queues (BullMQ) or pub/sub brokers where message loss is impermissible.

5. Production Implementation: Cache-Aside with XFetch & Mutex

The following production TypeScript blueprint demonstrates an enterprise Cache-Aside implementation featuring probabilistic early expiration and distributed mutex locking to prevent thundering herds:

💻 Production Redis Cache-Aside with XFetch & Jitter

import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: 6379,
  maxRetriesPerRequest: 3
});

interface CacheRecord<T> {
  value: T;
  delta: number;   // Compute time in milliseconds
  expiry: number;  // Absolute epoch timestamp (ms)
}

export async function fetchWithProbabilisticCache<T>(
  key: string,
  baseTtlSeconds: number,
  computeFn: () => Promise<T>,
  beta: number = 1.0
): Promise<T> {
  const cachedJson = await redis.get(key);
  const now = Date.now();

  if (cachedJson) {
    try {
      const record: CacheRecord<T> = JSON.parse(cachedJson);
      const remainingTtl = record.expiry - now;

      // XFetch probabilistic calculation: -delta * beta * ln(rand) > remaining
      const shouldEarlyRefresh = -record.delta * beta * Math.log(Math.random()) > remainingTtl;

      if (!shouldEarlyRefresh) {
        return record.value;
      }

      // Early probabilistic refresh triggered: Attempt non-blocking lock to refresh
      const lockKey = `lock:${key}`;
      const acquiredLock = await redis.set(lockKey, '1', 'PX', 5000, 'NX');

      if (acquiredLock) {
        // Asynchronously refresh in background without stalling current read
        (async () => {
          try {
            const start = Date.now();
            const freshValue = await computeFn();
            const duration = Date.now() - start;
            await writeCachedRecord(key, freshValue, baseTtlSeconds, duration);
          } finally {
            await redis.del(lockKey);
          }
        })().catch(err => console.error('Background cache refresh failed:', err));

        // Return current stale value immediately to avoid blocking client
        return record.value;
      }

      return record.value;
    } catch {
      // Fallback on corrupt JSON
    }
  }

  // Cache miss: Execute compute under lock to prevent stampede
  const lockKey = `lock:${key}`;
  const acquiredLock = await redis.set(lockKey, '1', 'PX', 10000, 'NX');

  if (!acquiredLock) {
    // Another worker is computing; wait 100ms and retry read
    await new Promise(res => setTimeout(res, 100));
    return fetchWithProbabilisticCache(key, baseTtlSeconds, computeFn, beta);
  }

  try {
    const start = Date.now();
    const freshValue = await computeFn();
    const duration = Date.now() - start;
    await writeCachedRecord(key, freshValue, baseTtlSeconds, duration);
    return freshValue;
  } finally {
    await redis.del(lockKey);
  }
}

async function writeCachedRecord<T>(key: string, value: T, ttlSec: number, delta: number): Promise<void> {
  // Apply +/- 15% TTL jitter to prevent cache avalanches
  const jitterFactor = 0.85 + Math.random() * 0.3;
  const randomizedTtlSec = Math.round(ttlSec * jitterFactor);
  const expiry = Date.now() + randomizedTtlSec * 1000;

  const record: CacheRecord<T> = { value, delta, expiry };
  await redis.set(key, JSON.stringify(record), 'EX', randomizedTtlSec);
}

⚡ Moemecto Distributed Systems Advisory

Design fault-tolerant distributed systems, optimize database query execution plans, and architect zero-trust microservice meshes with Moemecto. Schedule an Architecture Review →

🛡️

Moemecto Distributed Systems & Software Architecture Group

Our engineering practice specializes in Domain-Driven Design (DDD), high-throughput message streaming with Apache Kafka, low-level C/C++ embedded IoT firmware, and Zero-Downtime continuous release pipelines.

Architecting Your Next Distributed Platform?

Collaborate with our software architects on microservices decomposition, IoT firmware, and database sharding.

Consult Engineering →