Module F-12·22 min read

The four caching strategies with their consistency guarantees, failure modes, and implementation trade-offs. Cache stampede prevention, invalidation timing, cache warm-up, and the decision framework for choosing the right pattern.

F-11 — Caching Patterns: Cache-Aside, Write-Through, Write-Behind, and Read-Through

Who this module is for: You use Redis as a cache — you call redis.get(), fall back to the database on miss, and call redis.set(). That is the cache-aside pattern, and it is correct for many situations. But it is not the only pattern, and it is not always the right one. This module covers the four caching strategies with their trade-offs, consistency implications, and the failure modes most tutorials skip.


What a Cache Actually Does

A cache sits between your application and your primary data store (typically a database). Its job is to answer repeated, expensive lookups cheaply by storing previously computed or fetched results in fast memory.

Every caching strategy makes a trade-off across three dimensions:

  1. Consistency — how closely the cache matches the source of truth at any given moment
  2. Complexity — how much application code is required to maintain the cache
  3. Failure behavior — what happens when the cache is empty, stale, or unavailable

Pattern 1: Cache-Aside (Lazy Loading)

Also called "lazy loading" or "look-aside cache." This is what most engineers mean when they say "we cached it in Redis."

How It Works

Application                     Redis                    Database
    │                              │                         │
    │──── GET user:1001 ──────────►│                         │
    │◄─── (nil) ──────────────────│                         │
    │                              │                         │
    │──── SELECT * FROM users ─────────────────────────────►│
    │◄─── {id:1001, name:...} ─────────────────────────────│
    │                              │                         │
    │──── SET user:1001 {..} EX 300►│                        │
    │                              │                         │
    │    [next request]            │                         │
    │──── GET user:1001 ──────────►│                         │
    │◄─── {id:1001, name:...} ────│                         │

Read path:

  1. Try GET key from Redis
  2. On hit: return the cached value
  3. On miss: query the database, store the result in Redis with a TTL, return the value

Write path: Write directly to the database. Optionally invalidate (delete) the cache key. Do not write to the cache on writes.

typescript
async function getUser(userId: string) { const key = `user:${userId}`; // 1. Try cache const cached = await redis.get(key); if (cached) return JSON.parse(cached); // 2. Cache miss — query database const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]); if (!user) return null; // 3. Populate cache with TTL await redis.set(key, JSON.stringify(user), 'EX', 300); return user; } async function updateUser(userId: string, data: Partial<User>) { // Write to database await db.query('UPDATE users SET ... WHERE id = $1', [userId]); // Invalidate cache — next read will re-populate from DB await redis.del(`user:${userId}`); }

When to Use Cache-Aside

  • Read-heavy workloads where most reads are for data that rarely changes
  • Resilient to cache failures — if Redis goes down, the application still works (just slower)
  • Infrequent writes — cache invalidation on every write is cheap

Trade-offs and Failure Modes

Cache stampede (thundering herd): If a popular key expires, many concurrent requests simultaneously see a miss and all rush to the database. The database receives N copies of the same expensive query. Solutions:

  • Probabilistic early expiry (PER): Recompute when TTL < threshold × random(). Some requests recompute before the key expires, warming the cache before the stampede.
  • Mutex / distributed lock: The first miss acquires a lock, computes, and populates the cache. Others wait and then read from the cache.
typescript
async function getUserWithLock(userId: string) { const key = `user:${userId}`; const lockKey = `lock:user:${userId}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); // Try to acquire lock (NX = only if not exists, EX = expire in 5s) const locked = await redis.set(lockKey, '1', 'NX', 'EX', 5); if (locked) { // We have the lock — compute and cache const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]); await redis.set(key, JSON.stringify(user), 'EX', 300); await redis.del(lockKey); return user; } else { // Another process is computing — wait briefly and retry await sleep(50); return getUserWithLock(userId); } }

Stale data: After an invalidation (DEL), the next request fetches fresh data from the database. But if you forget to invalidate after a write, the cache serves stale data until TTL expires.

Cache miss on cold start: After a deployment or Redis restart, the cache is empty. All requests hit the database simultaneously. Mitigate with cache warming (pre-populate common keys on startup).


Pattern 2: Write-Through

Write-through keeps the cache synchronously updated on every write. The application writes to the cache and the database together — the write is only acknowledged when both succeed.

How It Works

Application                     Redis                    Database
    │                              │                         │
    │──── Write user:1001 ────────►│                         │
    │     (SET user:1001 {...})     │                         │
    │                              │──── UPDATE users ───────►│
    │                              │◄─── OK ─────────────────│
    │◄─── OK ─────────────────────│                         │

Read path: Same as cache-aside — try cache, fall back to database.

Write path: Write to both cache and database atomically (or in sequence). The cache always reflects the latest write.

typescript
async function updateUser(userId: string, data: Partial<User>) { const key = `user:${userId}`; // Write to database first (source of truth) const updated = await db.query( 'UPDATE users SET name=$2, email=$3 WHERE id=$1 RETURNING *', [userId, data.name, data.email] ); // Synchronously update cache with fresh data await redis.set(key, JSON.stringify(updated), 'EX', 3600); return updated; }

When to Use Write-Through

  • Write-heavy with frequent re-reads — data is written once and read many times before the next write
  • Consistency is important — reads always see the latest write
  • Data must not be stale — user sessions, inventory counts, balances

Trade-offs and Failure Modes

Write latency doubles: Every write waits for both the database and Redis to acknowledge. If Redis is slow, all writes are slow.

Cache pollution: You write to the cache whether or not the data is ever read again. Short-lived objects (created once, never read) consume cache memory unnecessarily. Mitigate with appropriate TTLs.

Partial failure: If the database write succeeds but the Redis write fails (network blip), the cache is stale. If the database write fails but Redis succeeds, the cache is incorrect. Neither is catastrophic for cache-aside (you can retry), but for write-through, you should treat the database as the source of truth and consider the cache write a best-effort update.


Pattern 3: Write-Behind (Write-Back)

Write-behind acknowledges the write after updating only the cache. The cache write to the database happens asynchronously — in a background job or queue.

How It Works

Application                     Redis                    Database
    │                              │                         │
    │──── SET user:1001 {...} ────►│                         │
    │◄─── OK ─────────────────────│                         │
    │                              │                         │
    │     [background job]         │                         │
    │                              │──── UPDATE users ───────►│
    │                              │◄─── OK ─────────────────│

Writes go to Redis immediately. A background worker (queue consumer, cron job, or Redis keyspace notification listener) propagates changes to the database asynchronously.

When to Use Write-Behind

  • Write-heavy, latency-sensitive workloads — real-time gaming scores, analytics counters, activity feeds
  • Writes that can tolerate temporary inconsistency — if Redis crashes before the background flush, the write is lost
  • Aggregation before persistence — batching 1,000 counter increments into a single database UPDATE

Trade-offs and Failure Modes

Data loss on Redis crash: If Redis loses data (no persistence configured, or between AOF flushes) before the background job writes to the database, the writes are permanently lost. This is the most dangerous failure mode in caching. Only use write-behind when data loss is acceptable.

Ordering: The background job must process writes in order. If write B overwrites write A, but the background job processes B before A, the database ends up with stale data. Use a queue (Redis List or Stream) to ensure ordering.

Complexity: The background flush mechanism is additional infrastructure to build, test, and operate.


Pattern 4: Read-Through

Read-through is cache-aside with the cache itself responsible for loading from the database on a miss — the application talks only to the cache abstraction, which handles database fallback internally.

How It Works

The API is identical to cache-aside from the application's perspective:

typescript
const user = await cache.get('user:1001');

The difference is implementation: the cache layer is a wrapper that handles the miss logic itself. Libraries like cache-manager (Node.js) implement this pattern.

typescript
import cacheManager from 'cache-manager'; import redisStore from 'cache-manager-redis-yet'; const cache = await cacheManager.caching(redisStore, { ttl: 300, }); // Application code — no explicit miss handling const user = await cache.wrap('user:1001', async () => { return db.query('SELECT * FROM users WHERE id = $1', [1001]); });

cache.wrap checks the cache, calls the factory function on a miss, stores the result, and returns it — all transparently.

When to Use Read-Through

  • When you want to centralize cache miss logic in a single place
  • When multiple parts of the application cache the same keys — consistency of TTL and serialization
  • As a facade over cache-aside for cleaner application code

Choosing the Right Pattern

PatternRead consistencyWrite latencyData loss riskComplexity
Cache-AsideEventual (on miss)No overheadNoneLow
Write-ThroughStrong (immediate)2× (cache + DB)NoneMedium
Write-BehindEventualVery low (cache only)High (Redis crash)High
Read-ThroughEventual (on miss)No overheadNoneLow (library)

Decision framework:

  • Is Redis purely a read cache (source of truth is the DB)? → Cache-Aside
  • Do reads immediately after writes need fresh data? → Write-Through
  • Is write latency the primary concern and data loss is acceptable? → Write-Behind
  • Do you want clean application code with centralized miss handling? → Read-Through

Most production systems use Cache-Aside as the default and add Write-Through for specific high-consistency keys (sessions, inventory, balances).


Cache Invalidation Strategies

"There are only two hard things in computer science: cache invalidation and naming things." — Phil Karlton

The harder problem is not which pattern to use — it is knowing when to invalidate.

Invalidation on Write (Proactive)

Delete the cache key immediately after the corresponding database row is written:

typescript
await db.query('UPDATE products SET price = $2 WHERE id = $1', [productId, newPrice]); await redis.del(`product:${productId}`);

Risk: The DEL and the UPDATE are not atomic. A crash between the two leaves the cache stale. Mitigate by doing DEL before the UPDATE (stale but empty is safer than stale with wrong data) or by using a transaction with a message queue.

TTL-Based Expiry (Passive)

Let cache keys expire naturally. Stale data is served until TTL expires. Simple, but bounded staleness.

Event-Driven Invalidation

Use a message broker (Redis Streams, BullMQ, Kafka) to publish invalidation events. Cache consumers subscribe and delete their local copies:

typescript
// On database write: await redis.xadd('events:cache-invalidation', '*', 'key', `product:${productId}`); // Cache invalidation worker: while (true) { const events = await redis.xreadgroup('GROUP', 'cache-invalidators', 'worker-1', 'COUNT', '100', 'BLOCK', '1000', 'STREAMS', 'events:cache-invalidation', '>'); for (const [id, [, key]] of events?.[0]?.[1] ?? []) { await redis.del(key); await redis.xack('events:cache-invalidation', 'cache-invalidators', id); } }

This pattern is essential for multi-region deployments where each region has its own Redis instance and invalidations must propagate globally.


Cache Warm-Up

After a Redis restart or a new cache cluster deployment, the cache is empty. Every request is a miss until the cache fills — this is a "cold start." For high-traffic systems, a cold cache means a temporary database overload.

Warm-up strategies:

  1. Eager warm-up — on startup, fetch the most-accessed keys from the database and pre-populate the cache
  2. Shadow traffic — replay recent production traffic against the new cache instance before routing live traffic
  3. Gradual rollout — route 5% of traffic to the new Redis instance, let it warm, then gradually increase

Summary

  • Cache-Aside — read from cache, fall back to DB on miss, write to DB and invalidate cache on writes. Default pattern. Simple, resilient, eventual consistency.
  • Write-Through — write to both cache and DB synchronously. Strong read consistency after writes. Higher write latency. Good for sessions and balances.
  • Write-Behind — write to cache immediately, flush to DB asynchronously. Lowest write latency. Risk of data loss on Redis crash.
  • Read-Through — cache handles its own miss logic. Clean application code. Same consistency as cache-aside.
  • Cache stampede — expired popular key → N concurrent DB queries. Mitigate with mutex locks or probabilistic early expiry.
  • Cache invalidation — proactive DEL after DB writes, or TTL-based expiry. Neither is perfectly safe under concurrent modification.
  • Cache warm-up — pre-populate after cold start to protect the database from the thundering herd of cache misses.

This completes the Foundation tier. You have covered the full Redis data model, all major data structures and their encodings, TTL and eviction mechanics, HyperLogLog/Bitmaps/Geo, pipelining, Pub/Sub, Streams, memory internals, transactions, and caching patterns.

The Practitioner tier begins with the infrastructure questions: how does Redis persist data to survive a restart?

Next: P-1 — RDB Snapshots: Point-in-Time Persistence — how Redis writes its entire in-memory dataset to disk, the fork-and-copy-on-write mechanism, snapshot configuration, and when RDB is the right (and wrong) persistence strategy.

© 2026 Jatin Jain Saraf (JJS). All rights reserved.