Skip to content
← Caching · beginner · 14 min · 02 / 11

Cache Strategies

Cache-aside, read-through, write-through, write-behind — when to use each and what breaks when you don't.

cache-asideread-throughwrite-throughwrite-behindpatterns

Real-World Analogy

A cook who preps ingredients before service (write-through) versus one who grabs from the pantry only when needed (cache-aside) — same kitchen, different rhythm.

The Four Strategies

Every caching system is built on one of four patterns — or a combination. The choice determines who owns cache population, how stale data appears, and what happens on writes.

StrategyWho reads from DBWho writes to DBConsistency
Cache-asideApplicationApplicationEventual
Read-throughCache layerApplicationEventual
Write-throughApplicationCache layer → DBStrong
Write-behindApplicationCache layer (async)Eventual

Cache-Aside (Lazy Loading)

The most common pattern. The application owns all the logic: check cache, miss → fetch from DB, populate cache.

class UserService {
  constructor(
    private cache: RedisClient,
    private db: Database,
  ) {}

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

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

    // 2. Cache miss — fetch from DB
    const user = await this.db.users.findById(id);
    if (!user) throw new NotFoundError(`User ${id} not found`);

    // 3. Populate cache (TTL: 5 minutes)
    await this.cache.setex(cacheKey, 300, JSON.stringify(user));

    return user;
  }

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

    // Invalidate — don't update cache, let next read repopulate
    await this.cache.del(`user:${id}`);

    return user;
  }
}

Pros: Simple. Only caches what’s actually requested. Cache survives Redis restarts (just repopulates from DB on next request).

Cons: First request after a miss is slow. Can serve stale data if you forget to invalidate on write.

Cache-aside is the right default. Use it until you have a specific reason not to.

Read-Through

The cache layer itself fetches from the database on a miss, transparently to the application. The application only talks to the cache.

// The cache wraps the data source
class ReadThroughCache<T> {
  private store = new Map<string, { value: T; expiresAt: number }>();

  constructor(private loader: (key: string) => Promise<T>) {}

  async get(key: string): Promise<T> {
    const entry = this.store.get(key);
    if (entry && Date.now() < entry.expiresAt) {
      return entry.value; // hit
    }

    // miss — load through
    const value = await this.loader(key);
    this.store.set(key, { value, expiresAt: Date.now() + 300_000 });
    return value;
  }
}

// Application just calls get — doesn't know about DB
const userCache = new ReadThroughCache<User>(
  (id) => db.users.findById(id),
);

const user = await userCache.get('user:123');

Pros: Simpler application code — one place handles all cache population logic.

Cons: Cold start still has latency. Harder to handle cache misses differently (e.g., returning null vs throwing). Libraries like node-cache-manager implement this pattern.

Write-Through

Every write goes through the cache to the database. Cache and DB are always in sync.

class WriteThroughUserCache {
  async saveUser(user: User): Promise<void> {
    // Write to DB and cache atomically
    await Promise.all([
      this.db.users.upsert(user),
      this.cache.setex(`user:${user.id}`, 3600, JSON.stringify(user)),
    ]);
  }

  async getUser(id: string): Promise<User | null> {
    const cached = await this.cache.get(`user:${id}`);
    if (cached) return JSON.parse(cached);

    // Only miss on first access or after eviction
    const user = await this.db.users.findById(id);
    if (user) {
      await this.cache.setex(`user:${id}`, 3600, JSON.stringify(user));
    }
    return user;
  }
}

Pros: Cache is always fresh — no stale reads. No invalidation logic needed.

Cons: Write latency increases (two writes). Cache fills with data that may never be read. Works best with read-through to handle initial loads.

Write-through is often combined with read-through. Together they guarantee the cache is always consistent — but at the cost of write performance and potentially caching rarely-read data.

Write-Behind (Write-Back)

Writes go to cache immediately, then the cache asynchronously flushes to the database. The application gets fast write acknowledgment.

class WriteBehindCache {
  private dirtyKeys = new Set<string>();
  private flushInterval: NodeJS.Timeout;

  constructor(
    private cache: Map<string, unknown>,
    private db: Database,
    flushEveryMs = 1000,
  ) {
    // Flush dirty keys to DB every second
    this.flushInterval = setInterval(() => this.flush(), flushEveryMs);
  }

  async write(key: string, value: unknown): Promise<void> {
    this.cache.set(key, value); // immediate, synchronous
    this.dirtyKeys.add(key);   // mark for async flush
    // Returns immediately — DB write happens later
  }

  private async flush(): Promise<void> {
    const keys = [...this.dirtyKeys];
    this.dirtyKeys.clear();

    await Promise.all(
      keys.map(async (key) => {
        const value = this.cache.get(key);
        if (value !== undefined) {
          await this.db.set(key, value);
        }
      }),
    );
  }

  destroy(): void {
    clearInterval(this.flushInterval);
  }
}

Pros: Lowest write latency. Can batch multiple writes into one DB operation. Great for counters, view counts, analytics.

Cons: Data loss risk if cache crashes before flushing. Complex failure handling. Not appropriate for financial or critical data.

Never use write-behind for anything where losing a few seconds of writes is unacceptable — payments, inventory changes, user-generated content. The performance gain isn’t worth the data loss risk.

Refresh-Ahead

Proactively refresh cache before entries expire, based on access patterns.

class RefreshAheadCache<T> {
  private store = new Map<string, {
    value: T;
    expiresAt: number;
    refreshAt: number; // refresh when this passes, before expiry
  }>();

  constructor(
    private loader: (key: string) => Promise<T>,
    private ttlMs: number,
    private refreshThreshold = 0.8, // refresh when 80% of TTL has passed
  ) {}

  async get(key: string): Promise<T | null> {
    const entry = this.store.get(key);

    if (!entry) return null; // cold miss

    const now = Date.now();

    // Trigger background refresh if nearing expiry
    if (now > entry.refreshAt && now < entry.expiresAt) {
      this.refreshInBackground(key); // don't await
    }

    if (now > entry.expiresAt) return null; // expired

    return entry.value;
  }

  private async refreshInBackground(key: string): Promise<void> {
    const value = await this.loader(key);
    const now = Date.now();
    this.store.set(key, {
      value,
      expiresAt: now + this.ttlMs,
      refreshAt: now + this.ttlMs * this.refreshThreshold,
    });
  }
}

Pros: Eliminates most cache misses. Popular keys are never cold.

Cons: Wastes work refreshing keys that won’t be read again. Complex to implement correctly. Overkill for most applications.

Choosing a Strategy

Start here:
  Is your read:write ratio > 10:1?
    Yes → Cache-aside. Simple, effective.
    No  → Is write latency critical?
            Yes → Write-behind (accept data loss risk)
            No  → Write-through (if consistency matters)

Do you need zero stale reads?
  Yes → Write-through + short TTL
  No  → Cache-aside with reasonable TTL

Multiple services sharing the same data?
  Yes → Distributed cache (Redis) + cache-aside
  No  → In-process cache, don't bother with Redis

Key Design Rules

Always expire entries. A cache with no TTL is a memory leak with extra steps. Even a 24-hour TTL is better than never expiring.

Make cache keys deterministic. user:${id} not user_${Date.now()}. If the same inputs don’t produce the same key, you’ll never hit.

Namespace your keys. Prefix by service or entity type: auth:session:abc123, catalog:product:456. Prevents collisions when sharing Redis across services.

Serialize consistently. JSON.stringify produces different output depending on key order in some environments. Use a canonical serializer or be aware of this.

// Good: deterministic key
const key = `product:${productId}:v2`;

// Bad: non-deterministic
const key = `product:${productId}:${Date.now()}`;

// Good: namespaced
const key = `catalog:product:${productId}`;

// Canonical JSON for complex keys
import { stringify } from 'fast-json-stable-stringify';
const key = `search:${stringify({ query, page, filters })}`;