Skip to content

Distributed Caching: Consistency, Invalidation, Failure

Distributed caching patterns in depth: cache-aside, write-through, read-through and the invalidation strategies that survive real consistency demands.

5 min read
Architecture diagram showing cache layers between application servers and database with invalidation flows

Caching is the most powerful performance optimization in distributed systems and the most dangerous source of bugs. A well-implemented cache reduces database load by 90%. A poorly implemented one serves stale data for hours while developers wonder why users see yesterday's prices.

The difference between these outcomes isn't the cache technology—it's the caching pattern. Each pattern makes different trade-offs between consistency, performance, and complexity.

Cache-Aside: The Default Pattern

Cache-aside (lazy loading) is the most common pattern. The application manages the cache explicitly: check the cache, hit the database on miss, populate the cache.

tstypescript
// ❌ Naive cache-aside with race condition
async function getUser(id: string): Promise<User> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
 
  const user = await db.users.findById(id);
  await redis.set(`user:${id}`, JSON.stringify(user));
  // Race: another request might have updated the DB between
  // our read and our cache write, caching stale data
  return user;
}
tstypescript
// ✅ Cache-aside with TTL and stampede protection
import { Redis } from "ioredis";
 
class CacheAside<T> {
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number = 300
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cacheKey = `${this.prefix}:${key}`;
 
    // Try cache first
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      return JSON.parse(cached) as T;
    }
 
    // Stampede protection: only one request populates cache
    const lockKey = `${cacheKey}:lock`;
    const acquired = await this.redis.set(
      lockKey, "1", "EX", 10, "NX"
    );
 
    if (!acquired) {
      // Another request is fetching; wait and retry cache
      await new Promise(r => setTimeout(r, 100));
      const retried = await this.redis.get(cacheKey);
      if (retried) return JSON.parse(retried) as T;
    }
 
    // Fetch from source
    const value = await fetcher();
 
    // Cache with TTL
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.ttlSeconds
    );
 
    // Release lock
    await this.redis.del(lockKey);
 
    return value;
  }
 
  async invalidate(key: string): Promise<void> {
    await this.redis.del(`${this.prefix}:${key}`);
  }
}
 
// Usage
const userCache = new CacheAside<User>(redis, "user", 300);
const user = await userCache.get("user-123", () =>
  db.users.findById("user-123")
);

The lock prevents cache stampede: when thousands of requests hit an expired cache key simultaneously, only one request fetches from the database. The rest wait briefly and get the freshly cached result.

Write-Through: Consistency at the Cost of Latency

Write-through caching updates the cache synchronously with every database write. Reads are always fast, but writes pay the cost of both storage operations.

tstypescript
// ❌ Cache and DB can get out of sync
async function updateUser(id: string, data: Partial<User>) {
  await db.users.update(id, data);    // DB updated
  await redis.del(`user:${id}`);       // Cache invalidated
  // If the app crashes between these lines, cache is stale
}
tstypescript
// ✅ Write-through with atomic-like guarantees
class WriteThroughCache<T> {
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number
  ) {}
 
  async write(
    key: string,
    value: T,
    dbWriter: (value: T) => Promise<void>
  ): Promise<void> {
    const cacheKey = `${this.prefix}:${key}`;
 
    // Write to DB first (source of truth)
    await dbWriter(value);
 
    // Then update cache
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.ttlSeconds
    );
  }
 
  async read(key: string): Promise<T | null> {
    const cacheKey = `${this.prefix}:${key}`;
    const cached = await this.redis.get(cacheKey);
    return cached ? (JSON.parse(cached) as T) : null;
  }
}
 
// Usage
const productCache = new WriteThroughCache<Product>(redis, "product", 600);
 
await productCache.write(
  "prod-456",
  updatedProduct,
  async (product) => {
    await db.products.update(product.id, product);
  }
);

Write-through guarantees that the cache always has fresh data after a write. The trade-off is higher write latency (two operations instead of one) and the risk of caching data for keys that are rarely read.

Read-Through With Stale-While-Revalidate

Stale-while-revalidate serves slightly stale data immediately while refreshing the cache in the background. This eliminates cache-miss latency for users while keeping data reasonably fresh.

tstypescript
interface CacheEntry<T> {
  value: T;
  cachedAt: number;
  staleAfter: number;
  expireAfter: number;
}
 
class StaleWhileRevalidateCache<T> {
  private refreshing = new Set<string>();
 
  constructor(
    private redis: Redis,
    private prefix: string,
    private freshSeconds: number = 60,
    private staleSeconds: number = 300
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cacheKey = `${this.prefix}:${key}`;
    const raw = await this.redis.get(cacheKey);
 
    if (raw) {
      const entry: CacheEntry<T> = JSON.parse(raw);
      const now = Date.now();
 
      if (now < entry.staleAfter) {
        // Fresh: return immediately
        return entry.value;
      }
 
      if (now < entry.expireAfter) {
        // Stale but usable: return immediately, refresh in background
        this.refreshInBackground(key, cacheKey, fetcher);
        return entry.value;
      }
    }
 
    // Expired or missing: fetch synchronously
    return this.fetchAndCache(key, cacheKey, fetcher);
  }
 
  private async fetchAndCache(
    key: string,
    cacheKey: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    const value = await fetcher();
    const now = Date.now();
 
    const entry: CacheEntry<T> = {
      value,
      cachedAt: now,
      staleAfter: now + this.freshSeconds * 1000,
      expireAfter: now + this.staleSeconds * 1000,
    };
 
    await this.redis.set(
      cacheKey,
      JSON.stringify(entry),
      "EX",
      this.staleSeconds
    );
 
    return value;
  }
 
  private refreshInBackground(
    key: string,
    cacheKey: string,
    fetcher: () => Promise<T>
  ): void {
    if (this.refreshing.has(key)) return; // Already refreshing
    this.refreshing.add(key);
 
    this.fetchAndCache(key, cacheKey, fetcher)
      .finally(() => this.refreshing.delete(key));
  }
}

This pattern is excellent for data that changes periodically but doesn't need real-time accuracy—product catalogs, user profiles, configuration settings. Users always get a fast response, and the data is never more than staleSeconds old.

Multi-Level Caching

Production applications often use multiple cache layers: in-process memory, distributed cache (Redis), and CDN. Each layer serves different access patterns.

tstypescript
class MultiLevelCache<T> {
  private l1: Map<string, { value: T; expires: number }> = new Map();
  private l1MaxSize: number;
 
  constructor(
    private redis: Redis,
    private prefix: string,
    l1MaxSize: number = 1000,
    private l1TtlMs: number = 10000,
    private l2TtlSeconds: number = 300
  ) {
    this.l1MaxSize = l1MaxSize;
  }
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    // L1: In-process memory (microseconds)
    const l1Entry = this.l1.get(key);
    if (l1Entry && l1Entry.expires > Date.now()) {
      return l1Entry.value;
    }
 
    // L2: Redis (milliseconds)
    const cacheKey = `${this.prefix}:${key}`;
    const l2Value = await this.redis.get(cacheKey);
 
    if (l2Value) {
      const parsed = JSON.parse(l2Value) as T;
      this.setL1(key, parsed);
      return parsed;
    }
 
    // L3: Database (tens of milliseconds)
    const value = await fetcher();
    this.setL1(key, value);
    await this.redis.set(
      cacheKey,
      JSON.stringify(value),
      "EX",
      this.l2TtlSeconds
    );
 
    return value;
  }
 
  private setL1(key: string, value: T): void {
    // Simple eviction: remove oldest when full
    if (this.l1.size >= this.l1MaxSize) {
      const oldest = this.l1.keys().next().value;
      if (oldest !== undefined) {
        this.l1.delete(oldest);
      }
    }
 
    this.l1.set(key, {
      value,
      expires: Date.now() + this.l1TtlMs,
    });
  }
 
  async invalidate(key: string): Promise<void> {
    this.l1.delete(key);
    await this.redis.del(`${this.prefix}:${key}`);
    // Note: other application instances still have L1 cache
    // Publish invalidation event for cluster-wide L1 flush
    await this.redis.publish(
      `${this.prefix}:invalidate`,
      key
    );
  }
}

The L1 cache (in-process) has a very short TTL because it can't be invalidated across instances. The L2 cache (Redis) has a longer TTL because it's shared. This layered approach handles the hot-path reads in microseconds while maintaining reasonable consistency.

Handling Cache Failures Gracefully

Cache failures shouldn't cascade into application failures. When Redis goes down, the application should degrade to database-direct reads, not crash.

tstypescript
class ResilientCache<T> {
  private circuitOpen = false;
  private failureCount = 0;
  private lastFailure = 0;
 
  constructor(
    private redis: Redis,
    private prefix: string,
    private ttlSeconds: number,
    private failureThreshold: number = 5,
    private resetTimeMs: number = 30000
  ) {}
 
  async get(
    key: string,
    fetcher: () => Promise<T>
  ): Promise<T> {
    // Check circuit breaker
    if (this.circuitOpen) {
      if (Date.now() - this.lastFailure > this.resetTimeMs) {
        this.circuitOpen = false;
        this.failureCount = 0;
      } else {
        // Circuit open: skip cache entirely
        return fetcher();
      }
    }
 
    try {
      const cacheKey = `${this.prefix}:${key}`;
      const cached = await this.redis.get(cacheKey);
 
      if (cached) {
        this.failureCount = 0;
        return JSON.parse(cached) as T;
      }
 
      const value = await fetcher();
 
      // Best-effort cache write
      this.redis
        .set(cacheKey, JSON.stringify(value), "EX", this.ttlSeconds)
        .catch(() => this.recordFailure());
 
      return value;
    } catch {
      this.recordFailure();
      return fetcher();
    }
  }
 
  private recordFailure(): void {
    this.failureCount++;
    this.lastFailure = Date.now();
 
    if (this.failureCount >= this.failureThreshold) {
      this.circuitOpen = true;
    }
  }
}

The circuit breaker prevents repeated cache failures from adding latency to every request. After five Redis failures, the circuit opens and the application goes directly to the database until Redis recovers.

Key Takeaways

Choosing a caching pattern isn't about picking the "best" one—it's about matching the pattern to your consistency requirements. Cache-aside works for most read-heavy workloads. Write-through works when reads must always see the latest write. Stale-while-revalidate works when near-real-time is good enough and latency is critical.

The patterns that cause the most production incidents are those that ignore failure modes. Always implement stampede protection for cache-aside, always handle cache failures with circuit breakers, and always assume that multi-instance deployments need cross-instance invalidation. The cache that performs perfectly but serves stale data after an update is worse than no cache at all.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX