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.
What you'll learn
- ✓Cache-aside (lazy loading) pattern
- ✓Write-through vs write-back strategies
- ✓TTL policies and eviction
- ✓Choosing the right strategy for your use case
Prerequisites
- •Basic understanding of databases
- •Familiarity with key-value stores like Redis
Why Caching Matters
Databases are the bottleneck in most backend systems. A typical SQL query takes 5-50 milliseconds. A Redis cache lookup takes under 1 millisecond. When thousands of requests hit the same data, caching eliminates redundant database work and dramatically reduces latency.
But caching introduces a new problem: your data now lives in two places. Keeping them in sync is where the complexity lives, and that is exactly what caching strategies address.
Cache-Aside (Lazy Loading)
Cache-aside is the most common caching pattern. The application manages the cache explicitly. It checks the cache first, and on a miss, fetches from the database and populates the cache.
How It Works
- Application receives a request.
- Check the cache for the data.
- If found (cache hit), return it.
- If not found (cache miss), query the database.
- Store the result in the cache.
- Return the result.
Implementation
import redis
import json
from database import db
cache = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id: int) -> dict:
cache_key = f"user:{user_id}"
# Step 1: Check cache
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
# Step 2: Cache miss - query database
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if not user:
return None
# Step 3: Populate cache with TTL
cache.setex(cache_key, 3600, json.dumps(user)) # 1 hour TTL
return user
def update_user(user_id: int, data: dict) -> dict:
# Update database
user = db.query(
"UPDATE users SET name = %s, email = %s WHERE id = %s RETURNING *",
(data['name'], data['email'], user_id)
)
# Invalidate cache
cache.delete(f"user:{user_id}")
return user
Trade-offs
Strengths: Only caches data that is actually requested. Cache failures do not break the application since it falls back to the database. Simple to implement and reason about.
Weaknesses: First request for any piece of data is always slow (cache miss). Stale data is possible if something else updates the database directly. The application has to manage cache logic.
Write-Through
With write-through caching, every write goes to both the cache and the database synchronously. The application writes to the cache, and the cache layer writes to the database.
How It Works
- Application writes data to the cache.
- The cache immediately writes the data to the database.
- Both writes must succeed before the operation is considered complete.
- Reads always hit the cache first.
Implementation
class WriteThroughCache:
def __init__(self, cache_client, db_client):
self.cache = cache_client
self.db = db_client
def get(self, key: str) -> dict | None:
# Always try cache first
cached = self.cache.get(key)
if cached:
return json.loads(cached)
# Fallback to database on miss
record = self.db.find_by_key(key)
if record:
self.cache.set(key, json.dumps(record))
return record
def put(self, key: str, value: dict) -> dict:
# Write to database first
self.db.upsert(key, value)
# Then update cache
self.cache.set(key, json.dumps(value))
return value
def delete(self, key: str) -> None:
self.db.delete(key)
self.cache.delete(key)
Trade-offs
Strengths: Cache is always consistent with the database. No stale reads. Simple mental model: every read after a write is guaranteed to be fresh.
Weaknesses: Every write operation has the latency of both cache and database writes. Data that is written but never read still goes into the cache, wasting memory. Higher write latency compared to cache-aside.
Write-Back (Write-Behind)
Write-back caching writes to the cache immediately and asynchronously flushes to the database later. This prioritizes write speed over immediate consistency.
How It Works
- Application writes data to the cache.
- The write returns immediately.
- A background process periodically flushes dirty cache entries to the database.
- The database eventually catches up.
Implementation
import threading
import time
from collections import deque
class WriteBackCache:
def __init__(self, cache_client, db_client, flush_interval=5):
self.cache = cache_client
self.db = db_client
self.dirty_keys = deque()
self.flush_interval = flush_interval
self._start_flusher()
def get(self, key: str) -> dict | None:
cached = self.cache.get(key)
if cached:
return json.loads(cached)
return self.db.find_by_key(key)
def put(self, key: str, value: dict) -> dict:
# Write only to cache
self.cache.set(key, json.dumps(value))
self.dirty_keys.append(key)
return value
def _start_flusher(self):
def flush_loop():
while True:
time.sleep(self.flush_interval)
self._flush()
thread = threading.Thread(target=flush_loop, daemon=True)
thread.start()
def _flush(self):
flushed = set()
while self.dirty_keys:
key = self.dirty_keys.popleft()
if key in flushed:
continue
flushed.add(key)
cached = self.cache.get(key)
if cached:
try:
self.db.upsert(key, json.loads(cached))
except Exception as e:
# Re-queue failed writes
self.dirty_keys.append(key)
print(f"Flush failed for {key}: {e}")
Trade-offs
Strengths: Very fast writes since only the cache is involved. Batches multiple writes to the database, reducing load. Good for write-heavy workloads.
Weaknesses: Risk of data loss if the cache crashes before flushing. Data in the database is stale until the flush completes. More complex to implement correctly, especially error handling.
Write-Around
Write-around skips the cache on writes entirely. Data goes directly to the database. The cache is only populated on reads (like cache-aside).
Implementation
class WriteAroundCache:
def __init__(self, cache_client, db_client, ttl=3600):
self.cache = cache_client
self.db = db_client
self.ttl = ttl
def get(self, key: str) -> dict | None:
cached = self.cache.get(key)
if cached:
return json.loads(cached)
record = self.db.find_by_key(key)
if record:
self.cache.setex(key, self.ttl, json.dumps(record))
return record
def put(self, key: str, value: dict) -> dict:
# Write only to database, invalidate cache
self.db.upsert(key, value)
self.cache.delete(key) # Force next read to fetch fresh data
return value
Trade-offs
Strengths: Avoids cache pollution from data that is written but rarely read. Good for workloads where writes greatly outnumber reads.
Weaknesses: First read after a write is always a cache miss. Data may be stale in cache until the entry expires or is invalidated.
TTL Policies
Time-to-live (TTL) is the simplest cache invalidation strategy. Every cached entry has an expiration time. After that time passes, the entry is automatically removed.
Choosing TTL Values
# Rarely changing data: long TTL
cache.setex("config:feature_flags", 86400, json.dumps(flags)) # 24 hours
# Frequently changing data: short TTL
cache.setex(f"user:{user_id}:feed", 60, json.dumps(feed)) # 1 minute
# Session data: match session duration
cache.setex(f"session:{session_id}", 1800, json.dumps(session)) # 30 minutes
# Real-time data: very short or no cache
cache.setex(f"stock:{symbol}", 5, json.dumps(price)) # 5 seconds
Jittered TTL
When many cache entries expire at the same time, all those requests hit the database simultaneously. This is called a cache stampede. Adding random jitter to TTLs spreads out the expiration.
import random
def set_with_jitter(cache, key: str, value: str, base_ttl: int):
jitter = random.randint(0, base_ttl // 10) # Up to 10% jitter
cache.setex(key, base_ttl + jitter, value)
# Instead of all keys expiring at exactly 3600s,
# they expire between 3600s and 3960s
set_with_jitter(cache, "product:1", data, 3600)
set_with_jitter(cache, "product:2", data, 3600)
Eviction Policies
When the cache runs out of memory, it needs to decide which entries to remove. Redis supports several eviction policies:
- noeviction: Return errors when memory is full. No data loss, but writes fail.
- allkeys-lru: Evict the least recently used key. Best general-purpose policy.
- allkeys-lfu: Evict the least frequently used key. Good when some keys are consistently hot.
- volatile-lru: Only evict keys with a TTL set, least recently used first.
- volatile-ttl: Evict keys with the shortest remaining TTL.
# Set eviction policy in Redis
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# Set memory limit
redis-cli CONFIG SET maxmemory 256mb
For most applications, allkeys-lru is the right default.
Cache Invalidation Patterns
Event-Driven Invalidation
Invalidate cache entries when the underlying data changes, using events or database triggers:
# After updating a user in the database
def on_user_updated(user_id: int):
cache.delete(f"user:{user_id}")
cache.delete(f"user:{user_id}:profile")
cache.delete("users:list") # Invalidate list cache too
# Using a message queue for distributed invalidation
def publish_invalidation(entity_type: str, entity_id: int):
message = json.dumps({"type": entity_type, "id": entity_id})
redis_client.publish("cache_invalidations", message)
Tag-Based Invalidation
Group related cache entries with tags so you can invalidate them together:
def set_with_tags(cache, key: str, value: str, tags: list[str], ttl: int):
cache.setex(key, ttl, value)
for tag in tags:
cache.sadd(f"tag:{tag}", key)
def invalidate_by_tag(cache, tag: str):
keys = cache.smembers(f"tag:{tag}")
if keys:
cache.delete(*keys)
cache.delete(f"tag:{tag}")
# Usage
set_with_tags(cache, "product:42", data, ["products", "category:electronics"], 3600)
# When any electronics product changes
invalidate_by_tag(cache, "category:electronics")
Choosing a Strategy
| Strategy | Read Speed | Write Speed | Consistency | Complexity |
|---|---|---|---|---|
| Cache-aside | Fast (after first) | Normal | Eventual | Low |
| Write-through | Fast | Slow | Strong | Medium |
| Write-back | Fast | Very fast | Eventual | High |
| Write-around | Fast (after first) | Normal | Eventual | Low |
Use cache-aside when reads are much more frequent than writes and occasional staleness is acceptable. This covers most web applications.
Use write-through when you need strong consistency between cache and database and can tolerate slower writes.
Use write-back for write-heavy workloads where you can tolerate the risk of data loss on cache failure, such as analytics counters or activity logs.
Use write-around when data is written once and read rarely, or when you want to avoid cache pollution from bulk imports.
Wrapping Up
Caching is not a single technique but a family of strategies. Cache-aside gives you the most control with the least complexity. Write-through guarantees consistency at the cost of write latency. Write-back optimizes for write speed but risks data loss. TTL provides a safety net for all strategies, ensuring stale data eventually expires. Start with cache-aside and a sensible TTL, measure your hit rates, and evolve your strategy as your traffic patterns become clearer.
Related articles
- 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 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.
- Django Django Caching with Redis: Per-View, Template Fragment, and Low-Level
Speed up Django with Redis caching — per-view cache, template fragment cache, low-level cache API, and cache invalidation strategies.
- Django Django Caching Strategies
Compare per-view, template fragment, low-level, and per-site caching in Django and learn when each pays off.