Caching Strategies Deep Dive

Difficulty: Intermediate

Question

Explain different caching strategies and their trade-offs. How do you handle cache invalidation?

Answer

Caching stores frequently accessed data in a faster storage layer to reduce latency and database load.

Caching strategies: 1. Cache-Aside (Lazy Loading): App checks cache first, loads from DB on miss, stores in cache. Most common. 2. Write-Through: App writes to cache and DB simultaneously. Consistent but slower writes. 3. Write-Behind (Write-Back): App writes to cache only; cache async writes to DB. Fast writes but risk of data loss. 4. Read-Through: Cache automatically loads from DB on miss (cache manages the data source).

Cache invalidation approaches: - TTL (Time To Live): Expire after N seconds. Simple but may serve stale data. - Event-based: Invalidate on write events. Consistent but complex. - Versioning: Include version in cache key. Clean but increases cache misses.

Eviction policies: LRU (Least Recently Used), LFU (Least Frequently Used), FIFO.

Code examples

Cache-Aside Pattern

// Cache-Aside (most common pattern)
async function getUser(userId) {
  const cacheKey = `user:${userId}`;

  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);  // Cache HIT
  }

  // 2. Cache MISS - query database
  const user = await db.users.findById(userId);
  if (!user) return null;

  // 3. Store in cache with TTL
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);

  return user;
}

// Invalidate on update
async function updateUser(userId, data) {
  await db.users.update(userId, data);
  await redis.del(`user:${userId}`);  // Delete cached version
}

// Problem: race condition between delete and next read
// Solution: set short TTL instead of immediate delete
async function updateUserSafe(userId, data) {
  const updated = await db.users.update(userId, data);
  await redis.set(`user:${userId}`, JSON.stringify(updated), 'EX', 300);
}

Cache-aside is simple and works well for read-heavy workloads. The app controls both cache reads and writes. On update, either delete the key or overwrite with fresh data.

Write-Through vs Write-Behind

// Write-Through: write to cache AND DB synchronously
async function writeThrough(key, value) {
  // Both must succeed
  await db.set(key, value);
  await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', 3600);
  // Pro: cache is always consistent
  // Con: slower writes (two operations)
}

// Write-Behind: write to cache, async write to DB
class WriteBehindCache {
  constructor() {
    this.writeBuffer = [];
    setInterval(() => this.flush(), 5000); // Flush every 5s
  }

  async set(key, value) {
    await redis.set(`cache:${key}`, JSON.stringify(value));
    this.writeBuffer.push({ key, value, timestamp: Date.now() });
    // Returns immediately - DB write happens later
  }

  async flush() {
    if (this.writeBuffer.length === 0) return;
    const batch = this.writeBuffer.splice(0);

    try {
      await db.batchWrite(batch);
    } catch (error) {
      // Re-queue failed writes
      this.writeBuffer.unshift(...batch);
      console.error('Write-behind flush failed:', error);
    }
  }
}
// Pro: very fast writes
// Con: data loss if cache crashes before flush

Write-through guarantees consistency at the cost of write latency. Write-behind maximizes write speed but risks data loss. Choose based on your consistency requirements.

Multi-Layer Caching

// Caching hierarchy (fastest to slowest)

// Layer 1: In-memory (Node.js process)
const localCache = new Map();
const LOCAL_TTL = 10_000; // 10 seconds

// Layer 2: Distributed cache (Redis)
// Shared across all app instances

// Layer 3: Database query cache
// Built into PostgreSQL/MySQL

// Layer 4: CDN cache
// For static assets and cacheable API responses

async function getWithMultiLayerCache(key) {
  // L1: Local memory (fastest, per-instance)
  const local = localCache.get(key);
  if (local && local.expiry > Date.now()) {
    return local.value;
  }

  // L2: Redis (fast, shared)
  const redisValue = await redis.get(key);
  if (redisValue) {
    const parsed = JSON.parse(redisValue);
    localCache.set(key, { value: parsed, expiry: Date.now() + LOCAL_TTL });
    return parsed;
  }

  // L3: Database (slow, source of truth)
  const dbValue = await db.query(key);
  if (dbValue) {
    await redis.set(key, JSON.stringify(dbValue), 'EX', 300);
    localCache.set(key, { value: dbValue, expiry: Date.now() + LOCAL_TTL });
  }

  return dbValue;
}

// Cache stampede prevention
// Problem: when cache expires, all servers hit DB simultaneously
// Solution: use a lock so only one server refreshes
async function getWithLock(key) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

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

  if (acquired) {
    const value = await db.query(key);
    await redis.set(key, JSON.stringify(value), 'EX', 300);
    await redis.del(lockKey);
    return value;
  } else {
    await sleep(100);
    return getWithLock(key);  // Retry after brief wait
  }
}

Multi-layer caching progressively reduces load. Local cache avoids network calls for hot data. Cache stampede prevention ensures only one instance refreshes expired cache entries.

Key points

Concepts covered

Cache-Aside, Write-Through, Write-Behind, Cache Invalidation, Redis, Eviction Policies