Difficulty: Intermediate
Explain different caching strategies and their trade-offs. How do you handle cache invalidation?
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.
// 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: 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.
// 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.
Cache-Aside, Write-Through, Write-Behind, Cache Invalidation, Redis, Eviction Policies