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
- Redis Caching Patterns
- AWS ElastiCache Best Practices
- Facebook: Scaling Memcache
- Designing Data-Intensive Applications — Martin Kleppmann, Chapter 11