Steven's Knowledge

Patterns

Cache-aside, read/write-through, write-behind, TTL strategies, invalidation, stampede prevention

Patterns

The caching algorithms you'll see across systems. The right one depends on consistency requirements, write volume, and what happens if the cache is briefly stale.

Cache-Aside (Lazy Loading)

The default. The application is responsible for cache logic; the cache itself is dumb.

Read:                                    Write:
  app → cache GET                          app → DB UPDATE
  if hit: return                           app → cache DEL key (or SET new value)
  if miss:
    app → DB SELECT
    app → cache SET key, ttl
    return
async function get(id) {
  const cached = await redis.get(`item:${id}`);
  if (cached) return JSON.parse(cached);
  const fresh = await db.find(id);
  await redis.set(`item:${id}`, JSON.stringify(fresh), 'EX', 300);
  return fresh;
}

async function update(id, data) {
  await db.update(id, data);
  await redis.del(`item:${id}`);
}

Pros: simple, works everywhere, only caches what's actually read.

Cons: first read after invalidation pays full latency; race between concurrent reads on a cold key (see stampede prevention).

Read-Through

The cache fronts the DB. Reads go to the cache; the cache fetches from DB on miss. Application code doesn't see the difference.

// Library handles the "miss → fetch → populate" flow
const cache = createReadThroughCache({
  client: redis,
  loader: async (key) => db.find(key),
  ttl: 300,
});

const item = await cache.get(`item:${id}`);    // hit or miss, library handles it

Pros: simpler call sites; the cache layer enforces TTL and serialization consistently.

Cons: harder to add custom logic per call site; library must integrate with your data layer.

Most ORMs and many Redis client libraries offer read-through wrappers.

Write-Through

Writes go to the cache first; the cache writes to the DB synchronously:

Write:
  app → cache SET key, value
        cache → DB UPDATE
        cache returns OK

Pros: cache is always consistent with the DB (within the synchronous write boundary).

Cons: writes incur full DB latency; more complex; tighter coupling between cache and DB.

Used in stricter consistency scenarios; less common than cache-aside in web apps.

Write-Behind (Write-Back)

Writes go to the cache; the cache asynchronously flushes to the DB:

Write:
  app → cache SET key, value (immediate OK)
        background → DB UPDATE in batches

Pros: very fast writes; batched DB IO.

Cons: data can be lost if the cache crashes before the flush. Reserved for specific cases (analytics counters, rate-limit counters).

Refresh-Ahead

Predictive: the cache refreshes a hot entry before its TTL expires:

On read of `item:42` (TTL remaining < 60s):
  serve cached value AND
  asynchronously kick off a DB fetch + cache update

Pros: hot keys never miss; latency stays low.

Cons: added complexity; useful only when there's a clear "hot key" set.

Implement via a background worker or EXPIRE checks in the application.

TTL Strategies

PatternUse when
Short TTL (seconds to minutes)Frequently changing data; staleness intolerable
Long TTL (hours) + explicit invalidationMostly-static data, known write sites
Sliding TTL (touch on read)Per-session data; auto-expire idle entries
Versioned keys (item:42:v3)Avoid invalidation entirely; old versions expire naturally

Versioned keys are underrated. Mutation bumps the version (kept in DB metadata or counter); every read computes the key with the current version; stale versions expire on their own.

// Read
const version = await db.getItemVersion(id);   // could be cached too
const cached = await redis.get(`item:${id}:v${version}`);
// ...

// Write
await db.updateItemBumpVersion(id);    // version becomes v+1
// No cache invalidation needed; v values expire over TTL

Invalidation

The hard part. Two strategies, both essential:

TTL-Based (Eventual Consistency)

Every cached entry has a TTL. Writes don't actively invalidate; readers eventually re-fetch.

  • Pros: simple; no race conditions; survives missed invalidations.
  • Cons: windows of staleness; cache may be "warm but wrong" for up to TTL.

Explicit Invalidation (On Write)

Writes invalidate cache entries actively:

async function updateUserProfile(userId, changes) {
  await db.update(userId, changes);
  await redis.del(`user:profile:${userId}`);
  // Plus any computed views that include this user
  await redis.del(`user:dashboard:${userId}`);
  await redis.del(`feed:user:${userId}`);
}
  • Pros: fresh reads after writes.
  • Cons: must enumerate every cached projection of the data; missed invalidations = stale reads.

Common Approach: Both

A short TTL plus explicit invalidation gives you fresh-after-write AND eventual consistency if you miss one. Most production systems do this.

Stampede Prevention

A common disaster: hot key expires, 1000 concurrent requests miss simultaneously, all hammer the database at once. Three mitigations:

Lock the Refresh

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

  // Try to acquire lock — only one process refreshes
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
  if (acquired) {
    try {
      const fresh = await loader();
      await redis.set(key, JSON.stringify(fresh), 'EX', ttl);
      return fresh;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Wait briefly and retry
    await sleep(50);
    return getWithLock(key, loader, ttl);
  }
}

Soft Expiry with SETNX Marker

Cache values store both the data and a "stale-at" timestamp. Reads serve cached data; only the first reader past stale-at triggers a refresh.

Jittered TTL

If many keys are warmed at once, they expire at the same time. Add jitter:

await redis.set(key, value, 'EX', 300 + Math.floor(Math.random() * 60));

Negative Caching

Cache "this key doesn't exist" too, briefly:

const cached = await redis.get(`item:${id}`);
if (cached === '__missing__') return null;
if (cached) return JSON.parse(cached);

const fresh = await db.find(id);
if (!fresh) {
  await redis.set(`item:${id}`, '__missing__', 'EX', 60);
  return null;
}
// ... cache as usual

Prevents stampedes on lookups for non-existent IDs (common attack pattern).

Distributed Lock with Redis

Sometimes you need application-level mutual exclusion across pods:

const ok = await redis.set('lock:checkout:abc', clientId, 'EX', 10, 'NX');
if (!ok) throw new Error('checkout in progress');
try {
  // critical section
} finally {
  // Lua-scripted compare-and-delete to avoid releasing someone else's lock
  await redis.eval(
    `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
    1, 'lock:checkout:abc', clientId
  );
}

For correctness-critical locks, don't roll your own — use Redlock (multi-node redis lock) or a real coordination service (etcd, ZooKeeper, Consul).

Cache Warming

Cold caches after a deploy / restart are slow. Strategies:

  • Pre-populate the most-read keys at startup.
  • Replica-promote rather than restart when possible (Redis replicas already have data).
  • Persistent cache (save 60 1 in redis.conf) so restart doesn't wipe state.
  • Tiered TTL: long TTL on hot keys, short TTL on the long tail.

What's Next

You can pick the right caching pattern for the job. Best Practices covers operations — HA, sizing, eviction, persistence, and the common pitfalls.

On this page