Cache Read/Write Patterns
In a Nutshell
Once you decide to cache, you must decide how the cache and the database stay coordinated — who reads from what, who writes to what, and in what order. These are the caching patterns, and each makes a different trade-off between read speed, write speed, consistency, and complexity. The read patterns (cache-aside and read-through) differ in whether your application or the cache manages loading on a miss. The write patterns (write-through, write-behind, write-around) differ in when and how writes reach the database. Picking the right combination is one of the most consequential — and most commonly botched — caching decisions.

How It Actually Works
Read Patterns
Cache-Aside (Lazy Loading) — the most common. The application orchestrates: check the cache; on a miss, read the database and populate the cache itself.
read(key):
value = cache.get(key)
if value is None: # cache MISS
value = db.query(key)
cache.set(key, value, ttl)
return value
- ✅ Only requested data is cached (memory-efficient); cache failure doesn't break reads (fall back to DB).
- ⚠️ First request per key is slow (miss); risk of stale data; app code must handle the logic.
Read-Through — the cache sits in front of the DB and loads missing data itself, transparently to the app.
read(key):
return cache.get(key) # cache loads from DB on miss internally
- ✅ App code is simpler; loading logic centralized in the cache layer.
- ⚠️ Requires cache support/config; first-read latency still exists.
Write Patterns
| Pattern | Write Path | Consistency | Write Latency | Risk |
|---|---|---|---|---|
| Write-Through | Write to cache and DB synchronously | Strong (cache always fresh) | Higher (two writes) | Slower writes |
| Write-Behind (Write-Back) | Write to cache now, DB asynchronously later | Eventual | Lowest | Data loss if cache dies before flush |
| Write-Around | Write directly to DB, skip cache | Cache may be stale until next read | Normal | Cache miss on next read |
Write-Through: App → Cache → DB (both updated before ack; always consistent)
Write-Behind: App → Cache → [ack] ... later ... Cache → DB (batched)
Write-Around: App → DB (cache untouched; populated lazily on read)
Choosing a Combination
Read and write patterns combine. Common pairings:
| Combination | Good For |
|---|---|
| Cache-aside + write-around | Read-heavy data written infrequently; avoids caching write-only data |
| Read-through + write-through | Read-heavy data needing freshness; simpler app code |
| Cache-aside + write-through | Balanced; cache kept fresh on write, populated on read |
| Write-behind (with either) | Write-heavy workloads that tolerate eventual persistence (metrics, counters) |
The Classic Cache-Aside + Write Consistency Trap
The order of operations on a write matters enormously. A naive "update DB, then update cache" has a race condition:
❌ Update cache then DB (or DB then cache) both have races:
Two concurrent writers can interleave and leave the cache holding
an older value than the database.
✅ Safer: update the DB, then INVALIDATE (delete) the cache entry.
The next read misses and repopulates from the fresh DB value.
Deleting is safer than updating because a stale delete just causes
a harmless re-fetch, not a persistent wrong value.
This is why the widely-recommended pattern is cache-aside for reads + write to DB then delete the cache key — invalidation, not update. (More in Cache Invalidation.)
Guarding Against the Thundering Herd
When a popular key expires, many concurrent requests all miss and stampede the database simultaneously:
Popular key "homepage" expires at t=0.
1000 requests arrive at t=0 → all miss → all hit the DB at once → 💥
Mitigations: a mutex/lock so only one request recomputes while others wait, early/probabilistic recomputation before expiry, or serving stale-while-revalidate.

Seeing It in Action
Scenario: Cache-aside with safe invalidation and stampede protection in a web app.
def get_product(product_id):
key = f"product:{product_id}"
value = cache.get(key)
if value is not None:
return value # HIT
# MISS — use a lock so only ONE request recomputes (no stampede)
with cache.lock(f"lock:{key}", timeout=5):
value = cache.get(key) # double-check after acquiring
if value is None:
value = db.query("SELECT * FROM products WHERE id=%s", product_id)
cache.set(key, value, ttl=300) # 5-min TTL
return value
def update_product(product_id, data):
db.update("UPDATE products SET ... WHERE id=%s", product_id, data)
# Write DB first, then INVALIDATE (delete) — not update — the cache key.
cache.delete(f"product:{product_id}")
# Next read misses and repopulates from the now-fresh DB value.
Why this is the pragmatic default: cache-aside keeps the app in control and degrades gracefully if the cache is down (reads fall back to the DB). Writing to the DB then deleting the key avoids the update-ordering races that plague "update the cache" approaches. The per-key lock prevents a popular key's expiry from stampeding the database. This combination — cache-aside, delete-on-write, lock-on-miss — handles the vast majority of real-world caching correctly.
Interview Questions
Q: Explain the cache-aside pattern and its main advantages and drawbacks. Hint: The application checks the cache; on a miss it reads the DB and populates the cache itself. Advantages: only requested data is cached (memory-efficient), and the cache being down doesn't break reads (fall back to DB). Drawbacks: first request per key is slow (cold miss), the app owns the logic, and stale data is possible — usually handled with TTLs and delete-on-write.
Q: Compare write-through, write-behind, and write-around. Hint: Write-through writes cache + DB synchronously (always-fresh cache, slower writes). Write-behind writes to cache immediately and flushes to DB asynchronously (fastest writes, eventual persistence, risk of loss if cache dies). Write-around writes only to the DB, leaving the cache to populate lazily on read (avoids caching write-only data, but next read misses).
Q: On a cache-aside write, why delete the cache key instead of updating it? Hint: Updating the cache introduces races — concurrent writers can interleave and leave the cache holding a stale value that persists. Deleting (invalidating) is safer: the next read simply misses and repopulates from the fresh DB value. A spurious delete only causes a harmless re-fetch, whereas a wrong update persists incorrect data. Order: write DB first, then delete the key.
Q: What is a thundering herd / cache stampede and how do you prevent it? Hint: When a popular key expires, many concurrent requests all miss and hit the database simultaneously, overwhelming it. Prevent with a mutex/lock so only one request recomputes while others wait or serve stale, probabilistic early recomputation before expiry, or stale-while-revalidate (serve the old value while refreshing in the background).
Q: Which read/write pattern combination would you choose for a read-heavy product catalog, and why? Hint: Cache-aside (reads) + write-around or delete-on-write. The catalog is read far more than written, so lazy-loading only caches what's actually requested, and writing to the DB then invalidating keeps things simple and consistent. Write-through is also reasonable if you want the cache always warm; write-behind is unnecessary since writes are infrequent.
References
- AWS: Caching patterns — cache-aside, write-through, write-behind
- Redis: Caching patterns — practical implementations
- Facebook: Scaling Memcache — real-world read/write and invalidation
Dive Deeper
- The problem with cache invalidation ordering (Kleppmann) — consistency subtleties
- Cache stampede mitigation (probabilistic early expiration) — the XFetch algorithm
- Designing Data-Intensive Applications by Martin Kleppmann — keeping derived data in sync