Caching Patterns: Cache-Aside, Write-Through, and Write-Behind
Understand the three core caching patterns with practical examples. Covers consistency tradeoffs, invalidation strategies, cache stampede prevention, and when to use each pattern.
What you'll learn
- ✓How cache-aside, write-through, and write-behind patterns work
- ✓How to prevent cache stampedes and stale data
- ✓How to choose the right pattern for your use case
Prerequisites
- •Basic understanding of key-value stores (Redis, Memcached)
- •Database query fundamentals
- •Backend application architecture
Caching is the most effective way to reduce latency and database load, but choosing the wrong caching pattern leads to stale data, inconsistencies, or cache stampedes. This guide covers the three fundamental patterns, their tradeoffs, and practical implementations.
Cache-aside (lazy loading)
In cache-aside, the application manages the cache explicitly. It checks the cache first, falls back to the database on a miss, and populates the cache with the result.
Read path:
1. Check cache for key
2. Cache hit? Return cached value
3. Cache miss? Query database
4. Store result in cache
5. Return result
Write path:
1. Write to database
2. Invalidate (delete) the cache key
Implementation
import redis
import json
cache = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id: int) -> dict:
cache_key = f"user:{user_id}"
# check cache
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
# cache miss: query database
user = db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
if user is None:
return None
user_dict = dict(user)
# populate cache with TTL
cache.setex(cache_key, 3600, json.dumps(user_dict)) # 1 hour TTL
return user_dict
def update_user(user_id: int, data: dict):
# write to database first
db.execute("UPDATE users SET name = %s WHERE id = %s", (data['name'], user_id))
db.commit()
# then invalidate cache
cache.delete(f"user:{user_id}")
Why delete instead of update the cache?
Deleting the cache key is safer than updating it. Consider this race condition with cache updates:
Thread A: reads user from DB (version 1)
Thread B: reads user from DB (version 2, newer)
Thread B: writes version 2 to cache
Thread A: writes version 1 to cache <-- stale data wins!
With deletion, the next read triggers a fresh database query, always returning the latest data.
Cache stampede prevention
When a popular cache key expires, many concurrent requests hit the database simultaneously. This is a cache stampede.
Solution 1: Locking
def get_user_with_lock(user_id: int) -> dict:
cache_key = f"user:{user_id}"
lock_key = f"lock:{cache_key}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
# try to acquire lock
acquired = cache.set(lock_key, "1", nx=True, ex=5) # 5 second lock
if acquired:
try:
user = db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
user_dict = dict(user)
cache.setex(cache_key, 3600, json.dumps(user_dict))
return user_dict
finally:
cache.delete(lock_key)
else:
# another process is refreshing, wait and retry
time.sleep(0.1)
return get_user_with_lock(user_id)
Solution 2: Stale-while-revalidate
def get_user_swr(user_id: int) -> dict:
cache_key = f"user:{user_id}"
cached = cache.get(cache_key)
if cached:
data = json.loads(cached)
if data.get('_expires_at', 0) < time.time():
# stale: return stale data but trigger background refresh
refresh_cache_async.delay(user_id)
return data
# true cache miss
return refresh_and_return(user_id)
Pros of cache-aside: Simple. Application controls exactly what is cached. Cache failures do not prevent reads (fallback to DB). Only requested data is cached.
Cons: First request for any data is always slow (cold cache). Application code must manage caching logic everywhere.
Write-through
In write-through, every write goes through the cache to the database. The cache is always consistent with the database.
Write path:
1. Write to cache
2. Cache synchronously writes to database
3. Return success only after both succeed
Read path:
1. Read from cache (always present for previously written data)
2. Cache miss? Query database and populate cache
Implementation
class WriteThroughCache:
def __init__(self, cache_client, db_client):
self.cache = cache_client
self.db = db_client
def write(self, key: str, value: dict):
"""Write to both cache and database synchronously."""
# write to database
self.db.execute(
"INSERT INTO kv_store (key, value) VALUES (%s, %s) "
"ON CONFLICT (key) DO UPDATE SET value = %s",
(key, json.dumps(value), json.dumps(value))
)
self.db.commit()
# write to cache
self.cache.setex(key, 3600, json.dumps(value))
def read(self, key: str) -> dict:
"""Read from cache, fallback to database."""
cached = self.cache.get(key)
if cached:
return json.loads(cached)
result = self.db.execute(
"SELECT value FROM kv_store WHERE key = %s", (key,)
).fetchone()
if result:
value = json.loads(result['value'])
self.cache.setex(key, 3600, json.dumps(value))
return value
return None
Pros: Cache is always consistent with the database. No stale reads after writes. Simplifies read path since data is always in cache after being written.
Cons: Higher write latency (must wait for both cache and DB). Writes data to cache that may never be read. If the cache is down, writes fail (unless you add a fallback).
Write-behind (write-back)
Write-behind writes to the cache immediately and asynchronously flushes to the database. This prioritizes write speed over consistency.
Write path:
1. Write to cache
2. Return success immediately
3. Asynchronously write to database (batched)
Read path:
1. Read from cache
Implementation
import threading
from collections import defaultdict
class WriteBehindCache:
def __init__(self, cache_client, db_client, flush_interval=5):
self.cache = cache_client
self.db = db_client
self.dirty_keys = set()
self.lock = threading.Lock()
# background flusher
self.timer = threading.Timer(flush_interval, self._flush)
self.timer.daemon = True
self.timer.start()
def write(self, key: str, value: dict):
"""Write to cache only, mark as dirty."""
self.cache.setex(key, 7200, json.dumps(value))
with self.lock:
self.dirty_keys.add(key)
def read(self, key: str) -> dict:
cached = self.cache.get(key)
return json.loads(cached) if cached else None
def _flush(self):
"""Periodically flush dirty keys to database."""
with self.lock:
keys_to_flush = self.dirty_keys.copy()
self.dirty_keys.clear()
for key in keys_to_flush:
value = self.cache.get(key)
if value:
try:
self.db.execute(
"INSERT INTO kv_store (key, value) VALUES (%s, %s) "
"ON CONFLICT (key) DO UPDATE SET value = %s",
(key, value, value)
)
except Exception as e:
# re-add to dirty set for next flush
with self.lock:
self.dirty_keys.add(key)
logger.error(f"Flush failed for {key}: {e}")
self.db.commit()
# reschedule
self.timer = threading.Timer(5, self._flush)
self.timer.daemon = True
self.timer.start()
Pros: Very fast writes (cache-only latency). Database writes are batched, reducing total DB operations. Good for write-heavy workloads like counters or analytics.
Cons: Risk of data loss if the cache crashes before flushing. Reads from the database are stale until the flush completes. Complex failure handling.
Choosing the right pattern
| Scenario | Pattern | Reason |
|---|---|---|
| General-purpose caching | Cache-aside | Simple, low risk, handles cache failures gracefully |
| Read-heavy with consistent reads | Write-through | Reads always see latest data |
| Write-heavy (counters, analytics) | Write-behind | Minimizes write latency, batches DB operations |
| User sessions | Cache-aside or write-through | Need consistency, acceptable latency |
| Leaderboards | Write-behind | High write frequency, eventual consistency OK |
| Product catalog | Cache-aside | Read-heavy, infrequent changes |
Cache invalidation strategies
Time-based (TTL)
Set a time-to-live on every cache key. Simple, but data can be stale for up to the TTL duration.
cache.setex("user:42", 300, data) # expires in 5 minutes
Event-based
Invalidate the cache when the underlying data changes. More complex, but data is never stale.
# publish invalidation event
def update_user(user_id, data):
db.update_user(user_id, data)
redis.publish("cache_invalidation", f"user:{user_id}")
# subscriber invalidates cache
def handle_invalidation(message):
cache.delete(message)
Versioned keys
Include a version in the cache key. Incrementing the version effectively invalidates the old key without explicitly deleting it.
version = cache.incr(f"user:{user_id}:version")
cache_key = f"user:{user_id}:v{version}"
Summary
Cache-aside is the safe default for most applications. Write-through adds consistency guarantees at the cost of write latency. Write-behind maximizes write throughput but risks data loss. In practice, most systems use cache-aside with TTL-based expiration and explicit invalidation on writes. Start there, and move to write-through or write-behind only when you have a specific performance requirement that cache-aside cannot meet.
Related articles
- Backend Caching Strategies: Write-Through, Write-Back, and TTL
Understand the major caching strategies for backend systems. Compare write-through, write-back, write-around, and cache-aside with real-world trade-offs.
- Backend Redis vs Memcached: Caching Solutions Compared
Compare Redis and Memcached for caching. Understand data structures, persistence, clustering, and use cases to choose the right caching solution.
- Backend Database Connection Pooling: Why and How
Understand why database connection pooling matters and how to configure it. Covers pool sizing, PgBouncer, application-level pools, and common misconfigurations.
- REST APIs REST API Caching: ETags, Cache-Control, and CDN Patterns
Master HTTP caching for REST APIs. Learn ETags, Cache-Control headers, conditional requests, and CDN integration patterns with practical examples.