Field note / fundamentals

publishedupdated 2026-05-01#caching#redis#performance#system-design#interview

Caching Strategies

Cache Aside, Read Through, Write Through, Write Back, Write Around — when to use each, trade-offs, and cache invalidation patterns.

Overview

Caching is the #1 performance tool in system design. Five strategies cover nearly every use case. Knowing which to use and why is a senior-engineer signal in interviews.

The two axes:

  • Read path: Cache Aside vs Read Through
  • Write path: Write Through vs Write Back vs Write Around

Architecture

CACHE ASIDE (Lazy Loading) — most common
════════════════════════════════════════════════════════
  Application                Cache           Database
      │                        │                 │
      │─── GET user:123 ──────▶│                 │
      │◀── cache MISS ─────────│                 │
      │─────────────────────────────── SELECT ──▶│
      │◀──────────────────────────────── row ────│
      │─── SET user:123 TTL 300 ──────────────▶  │  (app writes cache)
      │                        │                 │
      │─── GET user:123 ──────▶│                 │
      │◀── cache HIT ──────────│                 │  (fast path)

READ THROUGH — cache library handles miss
════════════════════════════════════════════════════════
  Application                Cache/Library    Database
      │                        │                 │
      │─── GET user:123 ──────▶│                 │
      │                        │── SELECT ───────▶│
      │                        │◀─ row ───────────│
      │◀── data ───────────────│                 │  (library populated cache)

WRITE THROUGH — write to cache AND DB synchronously
════════════════════════════════════════════════════════
  Application                Cache           Database
      │                        │                 │
      │─── SET user:123 ──────▶│                 │
      │                        │─── INSERT ──────▶│
      │                        │◀── ACK ──────────│
      │◀── ACK ────────────────│                 │  (both updated before return)
  Pros: cache always consistent  Cons: every write hits DB (no latency benefit)

WRITE BACK (Write Behind) — write cache only, flush async
════════════════════════════════════════════════════════
  Application                Cache           Database
      │                        │                 │
      │─── SET user:123 ──────▶│                 │
      │◀── ACK ────────────────│                 │  (fast return!)
      │                  (queue dirty writes)     │
      │                        │── batch INSERT ──▶│  (async, e.g. every 5s)
  Pros: low write latency  Cons: data loss risk if cache dies before flush

WRITE AROUND — write to DB, skip cache
════════════════════════════════════════════════════════
  Application                Cache           Database
      │                        │                 │
      │──────────────────────────── INSERT ─────▶│
      │◀─────────────────────────── ACK ─────────│
  (cache NOT updated — next read will be a miss)
  Pros: cache not polluted by write-once data
  Cons: first read after write is always a miss

CACHE INVALIDATION — the hardest part
════════════════════════════════════════════════════════
  Strategy 1: TTL (Time To Live)
    SET user:123 EX 300     ← auto-expire after 300s
    Trade-off: stale data for up to TTL; simple

  Strategy 2: Event-driven invalidation
    On DB write → DELETE cache:user:123
    Trade-off: always fresh; thundering herd risk

  Strategy 3: Version-based
    Key = user:123:v5  (embed version in key)
    Old keys become orphans, expire via TTL

Technical Implementation

Cache Aside in Node.js (Redis)

// src/services/user.service.ts
import { createClient } from 'redis';

export class UserService {
  private redis = createClient({ url: process.env.REDIS_URL });
  private readonly TTL = 300;  // 5 minutes

  async getUser(userId: string): Promise<User | null> {
    const cacheKey = `user:${userId}`;

    // 1. Try cache
    const cached = await this.redis.get(cacheKey);
    if (cached) return JSON.parse(cached);

    // 2. Cache miss — query DB
    const user = await this.db.users.findById(userId);
    if (!user) return null;

    // 3. Populate cache
    await this.redis.setEx(cacheKey, this.TTL, JSON.stringify(user));
    return user;
  }

  async updateUser(userId: string, updates: Partial<User>): Promise<User> {
    const user = await this.db.users.update(userId, updates);

    // Invalidate cache on write
    await this.redis.del(`user:${userId}`);

    return user;
  }
}

Write-Through in Java (Spring Cache)

@Service
@CacheConfig(cacheNames = "users")
public class UserService {

    @Cacheable(key = "#userId")   // Read Through — auto-populate on miss
    public User getUser(String userId) {
        return userRepository.findById(userId).orElseThrow();
    }

    @CachePut(key = "#user.id")   // Write Through — update cache on write
    public User updateUser(User user) {
        return userRepository.save(user);
    }

    @CacheEvict(key = "#userId")  // Invalidate on delete
    public void deleteUser(String userId) {
        userRepository.deleteById(userId);
    }
}

// application.yml (Redis cache config)
spring:
  cache:
    type: redis
    redis:
      time-to-live: 300000  # 5 minutes in ms
      cache-null-values: true  # cache nulls to prevent cache miss attack

Write-Back with Redis + Async Flush

// Writes go to Redis only; a background job flushes to DB
export class WriteBackCache {
  async write(key: string, value: unknown): Promise<void> {
    await this.redis.multi()
      .set(`data:${key}`, JSON.stringify(value))
      .sadd('dirty:keys', key)          // track dirty keys
      .expire(`data:${key}`, 3600)
      .exec();
  }

  // Run every 5 seconds
  async flush(): Promise<void> {
    const dirtyKeys = await this.redis.smembers('dirty:keys');
    if (dirtyKeys.length === 0) return;

    const pipeline = this.redis.pipeline();
    const writes: Promise<void>[] = [];

    for (const key of dirtyKeys) {
      const value = await this.redis.get(`data:${key}`);
      if (value) {
        writes.push(this.db.upsert(key, JSON.parse(value)));
      }
    }

    await Promise.all(writes);
    await this.redis.del('dirty:keys');
  }
}

Cache Miss Attack — Bloom Filter Defense

// Without protection: GET /users/nonexistent-id
// → cache miss → DB query → DB returns null → repeat 10K times → DB overloaded

// Protection 1: Cache null values
if (!user) {
  await this.redis.setEx(`user:${userId}`, 60, 'NULL');  // short TTL for nulls
}

// Protection 2: Bloom filter (pre-filter before any cache/DB lookup)
import { BloomFilter } from 'bloomfilter';
const bloom = new BloomFilter(32 * 256, 16);

// On startup, load all valid user IDs into bloom filter
const allIds = await this.db.users.getAllIds();
allIds.forEach(id => bloom.add(id));

async getUser(userId: string) {
  if (!bloom.test(userId)) return null;  // definitely not in DB — skip both cache and DB
  // ... normal cache aside logic
}

Interview Preparation

Q: Which cache strategy should you use for a read-heavy social feed?

Cache Aside (lazy loading). The feed is read frequently but written infrequently. On cache miss, load the feed from DB and cache it with a short TTL (60–300s). On write (new post), invalidate the feed caches for affected users. Lazy loading means you only cache what's actually requested — no wasted memory for cold data.

Q: When would you use Write Back?

When write latency is critical and you can tolerate some data loss risk. Examples: real-time analytics counters (page views, likes), game scores, IoT sensor data. The async flush to DB means a Redis crash loses recent writes. Mitigate with Redis persistence (AOF) or Redis Cluster replication.

Q: What is the thundering herd problem?

When a popular cache key expires, thousands of concurrent requests all see a cache miss simultaneously, all query the DB at once, overwhelming it. Solutions: (1) add random jitter to TTL (TTL = baseTime + random(0, 30s)) so keys don't expire simultaneously; (2) mutex/distributed lock — first miss acquires lock and populates cache, others wait; (3) probabilistic early expiration — randomly refresh slightly before expiry.

Q: What is cache stampede vs cache penetration vs cache breakdown?

  • Cache penetration: querying keys that don't exist in DB (malicious or bug) — every request hits DB. Fix: cache null values, Bloom filter.
  • Cache breakdown: a single popular key expires and many requests hit DB simultaneously. Fix: distributed lock, never-expire for hot keys.
  • Cache stampede (thundering herd): many keys expire at once. Fix: TTL jitter.

Learning Resources