We covered the basics of caching earlier. Now let’s go deeper into the different caching patterns and when to use each one. The difference between a well-cached system and a poorly-cached system isn’t whether we use a cache — it’s which pattern we pick.
The Five Caching Patterns
Cache-Aside (Lazy Loading)
The most common pattern. The application talks to the cache and the database directly. The cache doesn’t know the database exists.
Read path:
- App checks the cache
- Hit — return the cached value
- Miss — app reads from DB, writes the value into cache, then returns it
Write path:
- App writes to the database
- App deletes (invalidates) the cache entry
- Next read will re-populate the cache
Why it’s popular: Simple to implement. Cache only holds data that’s actually been requested. If the cache goes down, the app still works (just slower).
The catch: The first request for any key is always a cache miss. And there’s a small window between the DB write and cache invalidation where we serve stale data.
Read-Through
Looks similar to cache-aside from the app’s perspective, but the only difference is: the cache itself is responsible for loading data from the database on a miss. The app only ever talks to the cache.
Think of it like a library. We ask the librarian (cache) for a book. If they don’t have it, they go fetch it from the warehouse (DB) — we just wait.
Pros: Simpler application code. The data-loading logic lives in one place (the cache layer). Cons: Cache needs to know how to query the database. Cache failure means no data access at all.
Write-Through
Every write goes to the cache AND the database at the same time, synchronously. The cache acts as the primary interface for writes.
App writes "user:123" → Cache stores it → DB stores it → Done
Pros: Cache is always in sync with the DB. No stale data. Pairs perfectly with read-through — together they give us a fully consistent cache layer.
Cons: Higher write latency (two writes per operation). We end up caching data that might never be read, wasting memory.
Write-Behind (Write-Back)
Write to the cache immediately, then asynchronously flush to the database in the background. The app gets a fast response because it only waits for the cache write.
App writes "user:123" → Cache stores it → Returns immediately
↓ (async, batched)
DB stores it later
Pros: Super fast writes. We can batch multiple writes into one DB operation, reducing database load.
Cons: If the cache crashes before flushing to DB, we lose data. This is a real risk. Use this only when speed matters more than durability.
Good for: Write-heavy workloads like analytics counters, view counts, or gaming leaderboards where losing a few seconds of data is acceptable.
Refresh-Ahead
The cache proactively refreshes entries that are about to expire. If an entry’s TTL is 60 seconds, the cache might refresh it at the 50-second mark — before anyone asks for it.
Pros: Hot data never experiences a cache miss. Users get consistently fast responses.
Cons: We’re refreshing data that might not be needed. If we predict wrong, we waste resources refreshing data nobody’s reading.
Good for: Data that’s read very frequently and is expensive to compute (dashboards, popular product pages, trending feeds).
The Cache Stampede Problem
Also called the thundering herd. Here’s the scenario:
- A popular cache key expires
- 1000 requests come in simultaneously for that key
- All 1000 requests see a cache miss
- All 1000 requests hit the database at the same time
- The database collapses under the load
This is a real problem for hot keys with millions of reads.
Solutions
Locking (Mutex): When a cache miss happens, the first request acquires a lock, fetches from DB, and populates the cache. All other requests wait for the lock to release, then read from cache.
Early expiration (Staggered TTL): Add a random jitter to TTL values so not all keys expire at the same time. Instead of all keys expiring at exactly 60s, they expire between 55-65s.
Refresh-ahead: Proactively refresh before expiry, so the key never actually expires for readers.
Never expire + background refresh: Set keys to never expire. A background job periodically refreshes them. Readers always get a cache hit (possibly slightly stale).
Distributed Caching
When our app runs on multiple servers, a local in-memory cache (like a HashMap) on each server has problems — different servers have different data, and when a server restarts, its cache is gone.
The solution: a shared distributed cache that all servers read from and write to.
Redis Cluster
Redis is the go-to for distributed caching. Redis Cluster splits data across multiple Redis nodes using hash slots (16,384 slots total). Each node owns a range of slots. If one node fails, a replica takes over.
Memcached
Simpler than Redis. Pure key-value store. Uses consistent hashing to distribute keys across multiple servers. Slightly faster than Redis for simple get/set operations because it does less.
When to Use Which
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | Lists, sets, sorted sets, hashes | Simple key-value only |
| Persistence | Optional disk persistence | None |
| Replication | Built-in | None |
| Pub/Sub | Yes | No |
| Best for | Feature-rich caching, sessions, leaderboards | Simple, high-throughput caching |
In simple language, caching patterns are about who loads the data, who writes the data, and when. Cache-aside is the safe default — we manage everything ourselves. Read-through and write-through let the cache do more work. Write-behind is fast but risky. And always plan for the stampede — because when a hot key expires, the thundering herd is coming.