An interviewer asked: "What caching strategy does your app use?"
The candidate said: "We use Redis."
Interviewer: "That's a tool. I asked for a strategy."
Silence. Interview over. 😶
Here's every caching strategy broken down 👇
🧠 𝗪𝗵𝗮𝘁 𝗶𝘀 𝗖𝗮𝗰𝗵𝗶𝗻𝗴?
👉 Storing data temporarily so future requests are served faster
👉 Avoids hitting the original source — DB, API, server — every time
👉 Wrong strategy = stale data, crashes, data loss
Without cache: User → Server → Database (slow 🐢)
With cache: User → Cache (fast ⚡)
↓ miss only
Database
✔ Reduces latency
✔ Reduces database load
✔ Scales better under traffic
⚡ 1️⃣ 𝗖𝗮𝗰𝗵𝗲-𝗔𝘀𝗶𝗱𝗲 (𝗟𝗮𝘇𝘆 𝗟𝗼𝗮𝗱𝗶𝗻𝗴)
👉 App checks cache first. Miss → fetch DB → store in cache.
async function getUser(id) {
// Step 1 — check cache
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// Step 2 — cache miss, hit DB
const user = await db.findUser(id);
// Step 3 — store in cache for next time
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
return user;
}
✔ Only caches what's actually requested
✔ Cache failure doesn't break the app
❌ First request always slow — cold cache
❌ Risk of stale data between TTL cycles
🎯 2️⃣ 𝗪𝗿𝗶𝘁𝗲-𝗧𝗵𝗿𝗼𝘂𝗴𝗵
👉 Every write goes to cache AND database simultaneously
async function updateUser(id, data) {
// Write to DB and cache together
await db.updateUser(id, data);
await redis.set(`user:${id}`, JSON.stringify(data), 'EX', 3600);
}
✔ Cache always in sync with DB
✔ No stale reads after writes
❌ Write latency increases — two writes every time
❌ Cache fills with data that may never be read
👉 Best for: Read-heavy apps where freshness is critical
🚀 3️⃣ 𝗪𝗿𝗶𝘁𝗲-𝗕𝗲𝗵𝗶𝗻𝗱 (𝗪𝗿𝗶𝘁𝗲-𝗕𝗮𝗰𝗸)
👉 Write to cache instantly. Sync to DB asynchronously later.
User writes → Cache ✅ (instant response)
↓ async worker (batched every 5s)
Database 🕐 (eventually consistent)
async function updateScore(userId, score) {
// Instant write to cache
await redis.set(`score:${userId}`, score);
// Add to queue — worker syncs to DB later
await queue.add('syncScore', { userId, score });
}
✔ Blazing fast write performance
✔ Reduces DB write load — batch updates
❌ Risk of data loss if cache crashes before sync
❌ Complex to implement correctly
👉 Best for: Leaderboards, analytics, gaming, counters
🔄 4️⃣ 𝗥𝗲𝗮𝗱-𝗧𝗵𝗿𝗼𝘂𝗴𝗵
👉 App only talks to cache. Cache fetches from DB on miss.
App → Cache → (hit) → App ✅
App → Cache → (miss) → DB → Cache populates → App ✅
// App never touches DB directly
const user = await cacheProvider.get(`user:${id}`);
// Cache provider handles DB fetch internally on miss
✔ App logic stays clean — zero cache handling code
✔ Cache always populated after first request
❌ Cache provider must support read-through natively
❌ First request latency still exists
👉 Best for: Managed caches — AWS ElastiCache, DAX
⏰ 5️⃣ 𝗥𝗲𝗳𝗿𝗲𝘀𝗵-𝗔𝗵𝗲𝗮𝗱
👉 Cache proactively refreshes data before TTL expires
TTL = 60s
At 45s → background job pre-fetches fresh data
At 60s → cache already has new data ✅ zero miss latency
async function getWithRefreshAhead(key, fetchFn, ttl = 60) {
const cached = await redis.get(key);
const ttlRemaining = await redis.ttl(key);
// Refresh when 75% of TTL has passed
if (ttlRemaining < ttl * 0.25) {
fetchFn().then(data =>
redis.set(key, JSON.stringify(data), 'EX', ttl)
);
}
return cached ? JSON.parse(cached) : fetchFn();
}
✔ No latency spikes on cache expiry
✔ Always serving warm data
❌ May refresh data that's never requested — wasted compute
👉 Best for: Homepages, dashboards, trending feeds
🔑 6️⃣ 𝗖𝗮𝗰𝗵𝗲 𝗜𝗻𝘃𝗮𝗹𝗶𝗱𝗮𝘁𝗶𝗼𝗻 𝗦𝘁𝗿𝗮𝘁𝗲𝗴𝗶𝗲𝘀
👉 Knowing WHEN to clear cache is as important as caching itself
// TTL — expire after fixed time
await redis.set('user:1', data, 'EX', 3600); // expires in 1hr
// Event-based — clear on data change
async function updateUser(id, data) {
await db.updateUser(id, data);
await redis.del(`user:${id}`); // invalidate immediately
}
// Cache versioning — bump version on deploy
const key = `user:${id}:v2`; // old v1 cache naturally expires
✔ TTL — simple, automatic
✔ Event-based — precise, immediate
✔ Versioning — safe for deployments
🚨 𝗖𝗮𝗰𝗵𝗲 𝗣𝗿𝗼𝗯𝗹𝗲𝗺𝘀 𝗬𝗼𝘂 𝗠𝘂𝘀𝘁 𝗞𝗻𝗼𝘄
// Cache Stampede — TTL expires, 1000 users hit DB together
// Fix: mutex lock — only one request rebuilds cache
const lock = await redis.set('lock:user:1', 1, 'NX', 'EX', 5);
if (lock) { /* fetch DB and repopulate */ }
else { /* wait and retry */ }
// Cache Penetration — requests for non-existent data bypass cache
// Fix: cache null values too
await redis.set(`user:${id}`, 'NULL', 'EX', 60);
// Cache Avalanche — all keys expire at same time
// Fix: add random jitter to TTL
const ttl = 3600 + Math.floor(Math.random() * 300);
⚛️ 𝗙𝗿𝗼𝗻𝘁𝗲𝗻𝗱 𝗖𝗮𝗰𝗵𝗶𝗻𝗴 (𝗥𝗲𝗮𝗰𝘁)
// React Query — cache-aside in the frontend
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/user/${id}`),
staleTime: 5 * 60 * 1000, // fresh for 5 mins
cacheTime: 10 * 60 * 1000, // keep in memory for 10 mins
});
✔ staleTime — how long data is considered fresh
✔ cacheTime — how long unused data stays in memory
✔ React Query implements cache-aside pattern automatically
🚨 𝗖𝗼𝗺𝗺𝗼𝗻 𝗠𝗶𝘀𝘁𝗮𝗸𝗲𝘀
❌ Caching without a TTL — stale data lives forever
❌ Not handling cache miss gracefully — app crashes
❌ Caching user-specific data globally — data leaks
❌ Using Write-Behind without a reliable queue
❌ Ignoring cache stampede on high-traffic TTL expiry
💡 𝗦𝗲𝗻𝗶𝗼𝗿-𝗟𝗲𝘃𝗲𝗹 𝗜𝗻𝘀𝗶𝗴𝗵𝘁
There are only two hard problems in computer science: cache invalidation and naming things.
Choosing the wrong strategy doesn't slow your app — it silently corrupts it.
🎯 𝗜𝗻𝘁𝗲𝗿𝘃𝗶𝗲𝘄 𝗢𝗻𝗲-𝗟𝗶𝗻𝗲𝗿
Caching strategy is a tradeoff between consistency, latency, and complexity — Cache-Aside for flexibility, Write-Through for consistency, Write-Behind for write performance, Read-Through for clean app logic, and Refresh-Ahead for zero miss latency — with the right choice always depending on your read/write ratio, consistency requirements, and failure tolerance.
#SystemDesign #Backend #Caching #Redis #WebDevelopment #InterviewPrep #SoftwareEngineering #PerformanceOptimization #EngineeringMindset
Top comments (0)