Cache Invalidation Patterns: Cache-Aside, Write-Through, and Write-Behind
There's an old joke in computer science: "There are only two hard things: cache invalidation and naming things." This post is about the first one — the actual strategies real systems use to keep cached data from going stale, and the tradeoffs of each.
Why Invalidation Is the Hard Part
Caching data is easy. Storing a value with a GET/SET takes two lines of code. The hard part is: when the underlying data changes, how does your cache find out?
If it doesn't find out, users see stale data — a customer's order status stuck at "processing" after it's shipped, a price that was updated an hour ago but is still showing the old value at checkout. Getting this wrong is how caching bugs end up in production incident reports.
There are three dominant patterns. Each is a genuine, widely-used architectural choice — not "one is right and the others are wrong." The right one depends on your read/write ratio and your tolerance for stale data vs. write latency.
Pattern 1: Cache-Aside (a.k.a. Lazy Loading)
This is the most common pattern, and the one we implicitly used in Post 1.
How it works:
- Application checks the cache first.
- Cache hit → return cached data.
- Cache miss → read from the database, then write the result into the cache, then return it.
- On updates, the application writes to the database, then explicitly deletes (or updates) the cache key.
READ: WRITE:
App → Cache (miss) App → Database (update)
App → Database (read) App → Cache (DELETE key)
App → Cache (populate)
App → return to caller
Real-world example: product catalog page
import redis
import json
r = redis.Redis(decode_responses=True)
def get_product(product_id):
cache_key = f"product:{product_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
r.set(cache_key, json.dumps(product), ex=300) # 5 min TTL as a safety net
return product
def update_product_price(product_id, new_price):
db.execute("UPDATE products SET price = %s WHERE id = %s", new_price, product_id)
# Invalidate rather than update — simpler and avoids race conditions
r.delete(f"product:{product_id}")
Why delete instead of update the cache directly on write? Two reasons:
- Simplicity: you don't have to replicate your update logic in two places (DB and cache).
- Avoiding race conditions: if two concurrent writes update the DB in one order but the cache in the opposite order (due to network timing), the cache can end up permanently wrong until the TTL expires. Deleting just forces the next read to re-fetch fresh data.
Pros:
- Only caches data that's actually requested (memory-efficient — no wasted caching of unused data)
- Cache failures degrade gracefully — if Redis is down, you just fall back to hitting the DB every time (slower, but not broken)
Cons:
- First request after a miss/expiry is always slow (has to hit the DB) — known as the "cold start" or "cache stampede" risk if many requests miss simultaneously (more on this below)
- There's a small window between the DB write and cache deletion where a read could theoretically get stale data
When to use it: This is the default choice for most read-heavy workloads — product pages, user profiles, article content. If you're not sure which pattern to use, start here.
Pattern 2: Write-Through
How it works: every write goes through the cache first, and the cache itself is responsible for synchronously writing to the database before acknowledging the write. Reads always come from the cache.
WRITE: READ:
App → Cache.set(key, value) App → Cache (always a hit, data is fresh)
→ Cache writes to Database
→ Ack returned to App
Real-world example: user preference settings
def update_user_preference(user_id, key, value):
cache_key = f"prefs:{user_id}"
# Update cache
r.hset(cache_key, key, value)
# Synchronously persist to DB in the same operation
db.execute(
"INSERT INTO user_preferences (user_id, pref_key, pref_value) VALUES (%s,%s,%s) "
"ON CONFLICT (user_id, pref_key) DO UPDATE SET pref_value = %s",
user_id, key, value, value
)
# Cache is now guaranteed fresh — no separate invalidation step
Pros:
- Cache is always consistent with the DB (no stale-read window)
- Reads are always fast (cache is always warm for written data)
Cons:
- Write latency increases — every write pays the cost of both the cache write and the DB write
- You cache things immediately even if they're never read again — can waste memory on write-heavy, rarely-read data
When to use it: Good for data that's written occasionally but read very frequently right after, where consistency matters more than write speed — user settings, configuration data, account metadata.
Pattern 3: Write-Behind (Write-Back)
How it works: writes go to the cache immediately and are acknowledged right away. The cache asynchronously (and often in batches) flushes changes to the database later.
WRITE: (async, later)
App → Cache.set(key, value) Background worker →
→ Ack returned to App immediately reads changes from Redis
→ writes them to Database in batch
Real-world example: high-frequency view counters
def increment_view_count(article_id):
# Extremely fast — just an in-memory increment
r.incr(f"views:{article_id}")
# No DB write here at all — happens later, in batch
# Separate background job, run every 60 seconds via cron/worker
def flush_view_counts_to_db():
cursor = 0
while True:
cursor, keys = r.scan(cursor=cursor, match="views:*", count=100)
for key in keys:
article_id = key.split(":")[1]
count = r.get(key)
db.execute(
"UPDATE articles SET view_count = view_count + %s WHERE id = %s",
count, article_id
)
r.delete(key) # reset counter after flushing
if cursor == 0:
break
Pros:
- Fastest possible writes (no synchronous DB round-trip)
- Naturally batches many small writes into fewer, larger DB writes — great for high-frequency events like view counts, click tracking, or IoT sensor readings
Cons:
- Risk of data loss if Redis crashes before the async flush happens (mitigate with AOF persistence from Post 2, but it's still a real risk)
- More complex to build and reason about — you need a reliable background flushing mechanism
- Eventual consistency — the DB is intentionally behind the cache for a window of time
When to use it: High-volume, loss-tolerant metrics — view counters, "likes," analytics events, leaderboard updates. Not appropriate for anything where losing a write is unacceptable (payments, order status).
Comparing the Three
| Pattern | Write speed | Read speed | Consistency | Complexity | Good for |
|---|---|---|---|---|---|
| Cache-aside | Normal (DB speed) | Fast after warm-up | Eventually consistent (small window) | Low | General-purpose reads — product pages, profiles |
| Write-through | Slower (cache + DB) | Always fast | Strongly consistent | Medium | Settings, config, low-write-volume data |
| Write-behind | Fastest | Always fast | Eventually consistent (larger window) | High | View counts, analytics, high-frequency events |
A Problem That Crosses All Patterns: Cache Stampede
Imagine a popular product page cached with cache-aside and a 5-minute TTL. At the exact moment it expires, 500 concurrent requests all miss the cache simultaneously and all 500 hit the database at once, trying to repopulate the same key. This is called a cache stampede (or "thundering herd") and can take down a database that was otherwise perfectly healthy under cache-assisted load.
Fix 1: Locking (only one request repopulates, others wait)
import time
def get_product_with_lock(product_id):
cache_key = f"product:{product_id}"
lock_key = f"lock:{cache_key}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Try to acquire a short-lived lock
got_lock = r.set(lock_key, "1", nx=True, ex=10)
if got_lock:
try:
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
r.set(cache_key, json.dumps(product), ex=300)
return product
finally:
r.delete(lock_key)
else:
# Someone else is already repopulating — wait briefly and retry
time.sleep(0.05)
return get_product_with_lock(product_id)
Fix 2: Early/probabilistic expiry (jitter)
Instead of every cached item expiring at exactly the same moment, add randomness so expiries spread out over time:
import random
def cache_with_jitter(key, value, base_ttl=300):
jitter = random.randint(-30, 30) # +/- 30 seconds
r.set(key, value, ex=base_ttl + jitter)
This is simple, cheap, and prevents many keys that were all set at once (e.g., during a deploy or cache warm-up) from all expiring in the same instant later.
What's Next
Now that you know how to keep a single Redis instance's data correct and fresh, the next question is scale and reliability: what happens when your dataset outgrows one machine's RAM, or that one machine goes down? That's sharding and replication — covered next.
Comments
Join the discussionNo comments yet. Be the first to comment.
