Skip to main content

🗂️ Caching Patterns & Best Practices

Knowing how to SET and GET in Redis is like knowing how to hold a hammer — useful, but not yet carpentry. This lesson teaches the patterns: the handful of well-worn strategies for wiring a cache between your app and its database, and how to pick the right one for reads, writes, and traffic spikes.

Week 10 · Thursday: Caching Strategies · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Implement the cache-aside (lazy-loading) pattern and explain its trade-offs
  • Contrast read-through with cache-aside and centralize caching logic
  • Choose between write-through and write-behind for write-heavy paths
  • Use refresh-ahead to avoid latency spikes on popular keys
  • Recognize the thundering herd / cache stampede and defend against it with locks and stale-while-revalidate
  • Select the right pattern for a given read/write and consistency profile

Estimated Time: 70 minutes

Project: Build a reusable cache service and add stampede protection to a hot product endpoint.

In This Lesson

Why Patterns?

Every caching decision comes down to two questions: who fills the cache, and when. The patterns below are just different answers to those questions, each tuned for a different balance of speed, freshness, and complexity. You don't invent them per project — you recognize which one fits and reach for it.

The patterns split naturally into two families:

  • Read patterns decide how data gets into the cache: cache-aside, read-through, refresh-ahead.
  • Write patterns decide what happens to the cache when data changes: write-through, write-behind, write-around.
Read patterns versus write patterns as two families READ patterns Cache-Aside Read-Through Refresh-Ahead how data enters the cache WRITE patterns Write-Through Write-Behind Write-Around what happens on a change
Real systems mix and match — most apps pair a read pattern with a write pattern that suits their consistency needs.

Cache-Aside (Lazy Loading)

The workhorse. The application owns the logic: it checks the cache, and on a miss it loads from the database and populates the cache itself. The cache sits "aside" the main data path — it never talks to the database directly.

sequenceDiagram participant App as Application participant Cache as Redis participant DB as Database App->>Cache: Look for the key alt Cache hit Cache->>App: Return cached value else Cache miss Cache->>App: Report nothing stored App->>DB: Query the source of truth DB->>App: Return the row App->>Cache: Store value with a TTL end
async function getUserById(userId) {
  const cacheKey = `user:${userId}`;

  // Look in the cache first
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);       // HIT

  // MISS: load from the database, then backfill the cache
  const user = await db.users.findOne({ _id: userId });
  if (user) {
    await redis.set(cacheKey, JSON.stringify(user), { EX: 3600 });
  }
  return user;
}
👍 Advantages👎 Trade-offs
Only requested data is cached — efficient use of memoryThe first request for each key always misses (cold cache)
Simple and explicit; nothing magic happensApp code is scattered with cache logic unless you wrap it
Cache failure degrades to a slow-but-working DB readA popular expired key can trigger a stampede (see below)

Cache-aside is the default you should assume unless a problem pushes you elsewhere. It pairs naturally with a write-around or invalidate-on-write strategy, which is the whole of the next lesson.

Read-Through

Read-through asks the same question as cache-aside — "is it cached?" — but hides the answer behind a caching layer. Your application only ever calls cache.get(); the layer itself loads from the database on a miss. Same data flow, but the logic lives in one place instead of every call site.

// A reusable read-through cache service
class CacheService {
  constructor(redis) {
    this.redis = redis;
  }

  // Pass a key, a loader function, and an optional TTL.
  // The service handles hit/miss/backfill so callers don't have to.
  async get(key, loader, ttl = 3600) {
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached);       // HIT

    const data = await loader();                 // MISS -> load
    if (data != null) {
      await this.redis.set(key, JSON.stringify(data), { EX: ttl });
    }
    return data;
  }
}

// Callers stay clean — no hit/miss code anywhere:
const cache = new CacheService(redis);

const getUser    = (id) => cache.get(`user:${id}`,    () => db.users.findOne({ _id: id }));
const getProduct = (id) => cache.get(`product:${id}`, () => db.products.findOne({ _id: id }), 1800);

✅ When to prefer read-through

Reach for it once you have more than a couple of cached endpoints. Centralizing the hit/miss/backfill logic (the DRY principle) means you fix a caching bug once, and every caller inherits the fix. The data flow is identical to cache-aside — the win is purely in code organization and consistency.

Write-Through

Read patterns keep the cache fresh on the way in. Write patterns keep it fresh when data changes. In write-through, every update writes to the database and the cache in the same operation — so the cache is never behind the database.

sequenceDiagram participant App as Application participant DB as Database participant Cache as Redis App->>DB: Write the new value DB->>App: Acknowledge the write App->>Cache: Write the same value with a TTL Cache->>App: Acknowledge the update
async function updateUser(userId, changes) {
  // 1. Write to the source of truth first
  const updated = await db.users.findOneAndUpdate(
    { _id: userId },
    { $set: changes },
    { returnDocument: 'after' }
  );

  // 2. Immediately refresh the cache with the new value
  if (updated) {
    await redis.set(`user:${userId}`, JSON.stringify(updated), { EX: 3600 });
  }
  return updated;
}
👍 Advantages👎 Trade-offs
Cache always matches the database — reads are consistently freshEvery write pays two round trips, so writes are slower
High cache hit rate right after a writeCaches data that may never be read (wasted memory)

⚠️ Order matters, and so does failure

Write the database first, then the cache. If you write the cache first and the DB write fails, you've published data that doesn't exist. And if the cache write fails after a successful DB write, your TTL is the safety net that limits how long the two can disagree.

Write-Behind (Write-Back)

Write-through is safe but slow on writes. Write-behind flips the priority: write to the cache immediately, return to the user, and persist to the database asynchronously a moment later. You trade a small window of durability risk for much faster writes and the ability to batch database work.

sequenceDiagram participant App as Application participant Cache as Redis participant Queue as Write Queue participant DB as Database App->>Cache: Write the value now Cache->>App: Acknowledge instantly App->>Queue: Enqueue a database write Note over Queue,DB: later, in the background Queue->>DB: Flush queued writes in a batch DB->>Queue: Acknowledge the batch
class WriteBehindCache {
  constructor(redis, db) {
    this.redis = redis;
    this.db = db;
    this.queue = [];
    // Flush the queue to the database every 5 seconds
    setInterval(() => this.flush(), 5000);
  }

  async update(key, data, collection) {
    // Fast path: cache is updated and the caller returns immediately
    await this.redis.set(key, JSON.stringify(data), { EX: 3600 });
    this.queue.push({ collection, id: data._id, data });
    return data;
  }

  async flush() {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0, 100);   // take up to 100 at once
    try {
      await Promise.all(batch.map((op) =>
        this.db.collection(op.collection).updateOne({ _id: op.id }, { $set: op.data })
      ));
      console.log(`Flushed ${batch.length} writes`);
    } catch (err) {
      // On failure, put the batch back so we retry next tick
      this.queue.unshift(...batch);
      console.error('Flush failed, will retry:', err);
    }
  }
}

⚠️ The durability tax

If the process crashes between the cache write and the flush, those queued writes are lost. Write-behind is a great fit for high-volume, loss-tolerant data (analytics counters, activity logs) and a poor fit for anything you can't afford to lose (orders, payments). Production systems back the queue with a durable store like Redis Streams or a real queue so a crash doesn't drop data.

Refresh-Ahead

The other read patterns only refill a key after it expires — meaning someone always eats the slow miss. Refresh-ahead refreshes a hot key before it expires, in the background, so users keep getting instant hits and never see the reload.

async function getWithRefreshAhead(key, loader, ttl = 1800) {
  const cached = await redis.get(key);

  if (cached) {
    const remaining = await redis.ttl(key);
    // If we're in the last 25% of the TTL, kick off a background refresh
    // but DON'T wait for it — return the still-valid cached value now.
    if (remaining > 0 && remaining < ttl * 0.25) {
      loader()
        .then((fresh) => fresh && redis.set(key, JSON.stringify(fresh), { EX: ttl }))
        .catch((err) => console.error('Background refresh failed:', err));
    }
    return JSON.parse(cached);
  }

  // Cold miss: load synchronously
  const fresh = await loader();
  if (fresh) await redis.set(key, JSON.stringify(fresh), { EX: ttl });
  return fresh;
}

Advantages: near-zero misses for popular data and smooth, spike-free response times. Trade-offs: more complexity, and it can waste database work refreshing keys that were about to go idle. Use it only for a known-hot set of keys, not everything.

The Thundering Herd (Cache Stampede)

Here's the failure mode that bites every caching system eventually. A popular key expires. In the same instant, a thousand requests all miss, all rush to the database to recompute the same value, and the database — which the cache was supposed to protect — falls over. That surge is the thundering herd, also called a cache stampede.

graph TD Expire["Popular key expires"] --> Miss["1000 requests all miss at once"] Miss --> Stampede["1000 identical DB queries"] Stampede --> Overload["Database overloads"]

Defense 1: a lock so only one request recomputes

When a key misses, the first request grabs a short-lived lock and does the reload; everyone else waits briefly and reads the freshly cached value.

async function getWithLock(key, loader, ttl = 3600) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // Try to become the single "leader" that recomputes this key.
  // SET ... NX succeeds only if the lock key does not already exist.
  const lockKey = `lock:${key}`;
  const gotLock = await redis.set(lockKey, '1', { NX: true, PX: 5000 });

  if (!gotLock) {
    // Someone else is already loading — wait a beat, then read their result
    await new Promise((r) => setTimeout(r, 50));
    return getWithLock(key, loader, ttl);
  }

  try {
    const data = await loader();
    if (data != null) await redis.set(key, JSON.stringify(data), { EX: ttl });
    return data;
  } finally {
    await redis.del(lockKey);   // release the lock
  }
}

Defense 2: stale-while-revalidate

Rather than letting a key vanish, keep serving the slightly stale value while one background task refreshes it. Nobody ever hits an empty cache. Store the data with a logical "fresh until" timestamp and a longer physical TTL:

async function staleWhileRevalidate(key, loader, freshMs = 60000, ttl = 3600) {
  const raw = await redis.get(key);

  if (raw) {
    const { data, freshUntil } = JSON.parse(raw);
    if (Date.now() > freshUntil) {
      // Value is stale but still usable: refresh in the background...
      revalidate(key, loader, freshMs, ttl).catch(console.error);
    }
    return data;                 // ...and serve the stale value instantly
  }

  return revalidate(key, loader, freshMs, ttl);   // cold miss
}

async function revalidate(key, loader, freshMs, ttl) {
  const data = await loader();
  const entry = { data, freshUntil: Date.now() + freshMs };
  await redis.set(key, JSON.stringify(entry), { EX: ttl });
  return data;
}

💡 A third defense: TTL jitter

If you cache a batch of keys at the same moment with the same TTL, they all expire together and stampede together. Add a small random offset — EX: 3600 + Math.floor(Math.random() * 300) — so expirations spread out over a five-minute window instead of firing in unison.

Practice & Quiz

🏋️ Exercise 1: A read-through cache service

Goal: Write cachedFetch(key, loader, ttl) that returns the cached value on a hit, and on a miss calls loader(), caches the result, and returns it.

async function cachedFetch(key, loader, ttl = 300) {
    // TODO: hit -> return parsed cache; miss -> load, cache, return
}
💡 Hint

redis.get returns null on a miss. Only cache truthy results so you don't store null and mask a later insert.

✅ Solution
async function cachedFetch(key, loader, ttl = 300) {
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    const data = await loader();
    if (data != null) {
        await redis.set(key, JSON.stringify(data), { EX: ttl });
    }
    return data;
}

🏋️ Exercise 2: Add stampede protection

Goal: A single product page gets 5,000 requests/second. Its cache key just expired. Which one line, added when you cache the value, spreads out future expirations so this can't recur across a batch of keys?

✅ Solution
// Add jitter so keys cached together don't all expire together
const jitter = Math.floor(Math.random() * 300); // 0–5 min
await redis.set(key, JSON.stringify(data), { EX: 3600 + jitter });

For a single very hot key, pair this with the lock or stale-while-revalidate approach so only one request recomputes at a time.

🎯 Quick Quiz

Question 1: In the cache-aside pattern, who is responsible for loading data from the database on a miss?

Question 2: Which pattern risks losing data if the process crashes before persistence?

Question 3: What is the "thundering herd" problem?

Best Practices & Pitfalls

✅ Do

  • Default to cache-aside, and centralize it as a read-through service once you have several cached endpoints.
  • Always write the database before the cache on updates.
  • Add TTL jitter to batches so expirations don't synchronize.
  • Protect hot keys with a lock or stale-while-revalidate.
  • Let a cache failure degrade gracefully to a direct database read — never let it throw.

❌ Don't

  • Use write-behind for data you cannot afford to lose without a durable queue behind it.
  • Cache null results blindly — a later insert becomes invisible until the TTL lapses (unless you deliberately cache misses with a short TTL to prevent penetration).
  • Give every key the same TTL and cache them all at once.
  • Sprinkle raw get/set across dozens of route handlers when one service would do.

📖 Quick decision guide

  • Read-heavy, tolerant of brief staleness? Cache-aside / read-through with a sensible TTL.
  • Reads must be fresh right after writes? Write-through.
  • Writes are the bottleneck and some loss is OK? Write-behind with a durable queue.
  • A few very hot keys? Refresh-ahead or stale-while-revalidate.

Summary

🎉 Key Takeaways

  • Cache-aside is the default: the app checks the cache and backfills on a miss.
  • Read-through is cache-aside with the logic centralized in one caching layer.
  • Write-through keeps the cache always-fresh at the cost of slower writes; write-behind makes writes fast but risks data loss.
  • Refresh-ahead reloads hot keys before they expire so users never see a miss.
  • The thundering herd is beaten with locks, stale-while-revalidate, and TTL jitter.

📚 Additional Resources

🚀 What's Next?

Every pattern here assumed the cache would eventually be wrong and need clearing. Facing that head-on is one of the two hard problems in computing: Cache Invalidation — TTLs, event-based deletes, versioned keys, and pub/sub coordination.

🗂️ Patterns in the toolbox!

You can now wire a cache for reads, writes, and traffic spikes. Next: keeping it honest.