Skip to content

Cache Strategies

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

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 })}`;