Caching is one of the most powerful tools in a backend engineer's toolbox, but it's also one of the easiest to misuse. Done right, it can reduce database load by 90% and cut response times from hundreds of milliseconds to single digits. Done wrong, it introduces subtle bugs where users see outdated data. This lesson covers the patterns that actually work in production.
The fundamentals of caching
What belongs in a cache
Not everything should be cached. The best candidates are data that is expensive to compute, read far more often than it's written, and tolerable if slightly stale. User sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request. data, APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. responses from third parties, and expensive database aggregations are all good candidates.
// A simple cache-aside implementation with Redis
async function getUserProfile(userId) {
const cacheKey = `user:profile:${userId}`;
// 1. Check the cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss - fetch from database
const user = await db.query(
'SELECT id, name, email, avatar_url FROM users WHERE id = ?',
[userId]
);
// 3. Store in cache with a 5-minute TTL
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}Cache hit rates and why they matter
A cache hit means you served the request from cache. A cache miss means you had to go to the database. Your hit rate is the percentage of requests served from cache. A hit rate below 80% often means your TTLs are too short, your keys are too granular, or you're caching data that isn't actually requested repeatedly.
| Hit rate | Interpretation | Common cause |
|---|---|---|
| > 95% | Excellent | Stable data, good key design |
| 80–95% | Good | Normal for most apps |
| 60–80% | Investigate | TTLs too short or keys too specific |
| < 60% | Cache is barely helping | Wrong data being cached |
Cache invalidationWhat is cache invalidation?Removing or updating cached data when the original data changes, so users never see outdated information. strategies
TTLWhat is ttl?Time-to-Live - a countdown attached to cached data that automatically expires it after a set number of seconds.-based expiration
Setting a time-to-live is the simplest approach: after N seconds, the cache entry expires and the next request will fetch fresh data. It's easy to implement but means you might serve stale data right up until the TTL expires.
// Cache product details for 10 minutes
await redis.setex(`product:${productId}`, 600, JSON.stringify(product));
// Cache leaderboard for 30 seconds (changes frequently)
await redis.setex('leaderboard:top100', 30, JSON.stringify(leaderboard));
// Cache rarely-changing config for 1 hour
await redis.setex('app:config', 3600, JSON.stringify(config));Event-driven invalidation
Instead of waiting for TTL to expire, you can explicitly delete or update cache entries when the underlying data changes. This keeps your cache accurate but requires discipline, every place that writes to the database needs to also update the cache.
async function updateUserProfile(userId, updates) {
// 1. Update the database
await db.query(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
[updates.name, updates.email, userId]
);
// 2. Immediately invalidate the cache
await redis.del(`user:profile:${userId}`);
// The next request will fetch fresh data and repopulate the cache
}Write patterns
Cache-aside vs. write-through vs. write-behind
These three patterns describe how your application interacts with the cache when writing data.
// Cache-aside (most common): app manages cache explicitly
async function saveOrder(order) {
await db.insert('orders', order);
await redis.del(`user:orders:${order.userId}`); // invalidate
}
// Write-through: always write to cache AND database together
async function saveOrder(order) {
await db.insert('orders', order);
const orders = await db.query(
'SELECT * FROM orders WHERE user_id = ?', [order.userId]
);
await redis.setex(
`user:orders:${order.userId}`, 300, JSON.stringify(orders)
);
}
// Write-behind (async): write to cache immediately, flush to DB later
// Faster writes, but risk of data loss if cache crashes before flush
async function saveOrder(order) {
await redis.lpush('pending:orders', JSON.stringify(order));
// A background worker drains this queue into the database
}| Pattern | Read performance | Write performance | Risk |
|---|---|---|---|
| Cache-aside | Fast after first hit | Normal | Stale reads until invalidated |
| Write-through | Always fresh | Slower (two writes) | Low |
| Write-behind | Always fresh | Very fast | Data loss if cache crashes |
Preventing cache stampede
What it is and why it hurts
A cache stampede (sometimes called a thundering herd) happens when a popular cache key expires and dozens of requests all simultaneously find a cache miss and all try to rebuild the cache at once. Each one fires an expensive database query, which can overwhelm your database during a traffic spike.
// Naive approach - vulnerable to stampede
async function getLeaderboard() {
const cached = await redis.get('leaderboard');
if (cached) return JSON.parse(cached);
// If this expires during a traffic spike, 500 concurrent requests
// all reach here simultaneously and hammer the database
const data = await db.query('SELECT ... expensive aggregation ...');
await redis.setex('leaderboard', 60, JSON.stringify(data));
return data;
}
// Better: use a lock to let only one request rebuild the cache
async function getLeaderboard() {
const cached = await redis.get('leaderboard');
if (cached) return JSON.parse(cached);
const lockKey = 'lock:leaderboard';
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (acquired) {
// We got the lock - rebuild the cache
const data = await db.query('SELECT ... expensive aggregation ...');
await redis.setex('leaderboard', 60, JSON.stringify(data));
await redis.del(lockKey);
return data;
} else {
// Another request is rebuilding - wait briefly and try again
await new Promise(resolve => setTimeout(resolve, 100));
return getLeaderboard();
}
}Quick reference
| Strategy | Best for | Tradeoff |
|---|---|---|
| Short TTL (seconds) | Frequently changing data | May serve stale data briefly |
| Long TTL + explicit invalidation | User profiles, product data | Requires discipline on writes |
| Write-through | Read-heavy with predictable writes | Slightly slower writes |
| Write-behind | Write-heavy, latency-sensitive | Risk of data loss |
| Distributed lock | High-traffic single keys | Added complexity |