Production Engineering/
Lesson

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;
}
The cache-aside pattern (also called lazy loading) is the most common pattern. Data only enters the cache when it's requested, which means your cache naturally fills with the most frequently accessed data.

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 rateInterpretationCommon cause
> 95%ExcellentStable data, good key design
80–95%GoodNormal for most apps
60–80%InvestigateTTLs too short or keys too specific
< 60%Cache is barely helpingWrong data being cached
02

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-through caching is a variant where you update the cache at the same time as the database, rather than deleting it. This avoids a cold cache after writes but means your write path always touches both systems.
03

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
}
PatternRead performanceWrite performanceRisk
Cache-asideFast after first hitNormalStale reads until invalidated
Write-throughAlways freshSlower (two writes)Low
Write-behindAlways freshVery fastData loss if cache crashes
04

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();
  }
}
A simpler alternative is probabilistic early expiration: slightly before a cache entry expires, a small percentage of requests will treat it as a miss and refresh it, keeping the cache warm before the stampede can happen.
05

Quick reference

StrategyBest forTradeoff
Short TTL (seconds)Frequently changing dataMay serve stale data briefly
Long TTL + explicit invalidationUser profiles, product dataRequires discipline on writes
Write-throughRead-heavy with predictable writesSlightly slower writes
Write-behindWrite-heavy, latency-sensitiveRisk of data loss
Distributed lockHigh-traffic single keysAdded complexity