Module 2: Caching Patterns and TTL
Write-Through and Write-Behind
Overview
Cache-aside solves most read problems in the backend. But there are two scenarios where it isn't the right choice: critical data where you can't serve anything stale and massive writes where speed is critical. The other two patterns exist for those cases.
Write-through guarantees the cache is always in sync with the DB: you write to both at the same time. If a bank account's balance changes, the cache reflects it immediately — there's no way a later read sees the old balance. The cost: writes are slower (you're writing in 2 places instead of 1). For apps where consistency matters more than write speed, write-through is the answer.
Write-behind (also called write-back) does the opposite: it writes to Redis first (fast), returns to the client, and then an asynchronous worker flushes to PostgreSQL in batches. For high-write scenarios — analytics, logs, metrics, counters — write-behind drastically reduces the load on PostgreSQL. The cost: if Redis goes down before the flush, you lose data. Acceptable for analytics events, unacceptable for purchase orders.
This capsule teaches you to implement both patterns, compare them with cache-aside, and develop the judgment to decide which one to use for each endpoint of your API. By the end you'll have a decision matrix you'll apply directly to module 5's capstone project.
Write-through: guaranteed consistency
How it works
┌─────────┐
│ Client │
└────┬────┘
│ POST /products/{id}
▼
┌─────────────┐
│ FastAPI │
│ Endpoint │
└──────┬──────┘
│
├──── 1. Update PostgreSQL (50ms)
│
└──── 2. Update Redis (1ms) ◄─── synchronous, it waits for both
│
▼
Return (51ms total)
A later read:
┌─────────┐
│ Client │
└────┬────┘
│ GET /products/{id}
▼
┌─────────────┐ ┌────────┐
│ Endpoint │────────▶│ Redis │
└─────────────┘ GET └────────┘
│
▼
hit ✓ (always,
because we always
write to the cache)
The flow:
- A write request arrives
- Update PostgreSQL (slow, ~50 ms)
- Update Redis (fast, ~1 ms)
- Return to the client
- The guarantee: from this moment, any read of the same data will be a cache hit with the correct value
The tradeoff: writes are ~50% slower (50 ms + 1 ms vs just 50 ms), but reads are always fast.
Implementation
Create write_through.py:
"""
The write-through pattern: writing to the DB and the cache simultaneously.
"""
import time
import json
import logging
import redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
r = redis.Redis(host='localhost', port=6379, decode_responses=True, socket_timeout=2)
CACHE_TTL = 600 # write-through usually uses long TTLs (the data is always fresh)
# We simulate PostgreSQL
fake_db = {}
def db_create_account(account_id: int, data: dict):
"""Simulates an INSERT into PostgreSQL."""
time.sleep(0.05) # 50ms
fake_db[account_id] = data
def db_update_account(account_id: int, data: dict):
"""Simulates an UPDATE in PostgreSQL."""
time.sleep(0.05)
if account_id in fake_db:
fake_db[account_id].update(data)
return fake_db[account_id]
return None
def db_get_account(account_id: int) -> dict | None:
"""Simulates a SELECT from PostgreSQL."""
time.sleep(0.05)
return fake_db.get(account_id)
# ═══════════════════════════════════════════════════════════
# The write-through pattern
# ═══════════════════════════════════════════════════════════
def create_account(account_id: int, data: dict):
"""Write-through: write to the DB AND the cache."""
cache_key = f"account:{account_id}"
# 1. Write to the DB first (the source of truth)
db_create_account(account_id, data)
# 2. Write to the cache immediately
try:
r.set(cache_key, json.dumps(data), ex=CACHE_TTL)
except RedisError as e:
logger.warning(f"Cache write failed for {cache_key}: {e}")
# We don't fail the request: the data is in the DB, the next read will populate the cache
logger.info(f"Account {account_id} created (DB + cache)")
return data
def update_account(account_id: int, data: dict):
"""A write-through update."""
cache_key = f"account:{account_id}"
# 1. Update the DB
updated = db_update_account(account_id, data)
if updated is None:
return None
# 2. Update the cache (with the complete updated record)
try:
r.set(cache_key, json.dumps(updated), ex=CACHE_TTL)
except RedisError as e:
logger.warning(f"Cache update failed for {cache_key}: {e}")
return updated
def get_account(account_id: int) -> dict | None:
"""A normal read: with write-through, the cache always has the most recent value."""
cache_key = f"account:{account_id}"
try:
cached = r.get(cache_key)
if cached:
return json.loads(cached)
except RedisError as e:
logger.warning(f"Cache read failed: {e}")
# Cache miss (rare with write-through, but possible if the TTL expired)
account = db_get_account(account_id)
if account is None:
return None
try:
r.set(cache_key, json.dumps(account), ex=CACHE_TTL)
except RedisError:
pass
return account
if __name__ == "__main__":
# Demo
print("=== Write-Through Demo ===\n")
# Create an account (write-through)
start = time.time()
create_account(1, {"id": 1, "balance": 1000.0, "owner": "Alice"})
print(f"Create: {(time.time() - start) * 1000:.1f}ms (DB + cache)")
# An immediate read → always a hit (write-through guarantees this)
start = time.time()
acc = get_account(1)
print(f"Read 1 (hit): {(time.time() - start) * 1000:.1f}ms - {acc}")
# Update the balance
start = time.time()
update_account(1, {"balance": 1500.0})
print(f"Update: {(time.time() - start) * 1000:.1f}ms (DB + cache)")
# An immediate read → it sees the updated balance
start = time.time()
acc = get_account(1)
print(f"Read 2 (hit): {(time.time() - start) * 1000:.1f}ms - {acc}")
Output:
=== Write-Through Demo ===
Create: 51.4ms (DB + cache)
Read 1 (hit): 0.9ms - {'id': 1, 'balance': 1000.0, 'owner': 'Alice'}
Update: 52.1ms (DB + cache)
Read 2 (hit): 0.8ms - {'id': 1, 'balance': 1500.0, 'owner': 'Alice'}
The critical part: after the update, the immediate read sees balance: 1500.0. There's no window where the cache is stale. That's exactly what write-through is worth.
When to use write-through
✅ Data where stale is unacceptable:
- Account balances, financial transactions
- Authorization permissions/roles
- Inventory in e-commerce (you don't want to sell what you don't have)
- Critical configuration that affects security
✅ Reads far more frequent than writes:
- If you write once per hour but read 1000 times, the write's extra cost gets diluted
- Reads are always hits → excellent performance
❌ Do NOT use write-through when:
- Writes are frequent (every write is 2x slower)
- The data is so stale-tolerable that cache-aside with a TTL is enough
- You'd cache EVERYTHING, including data nobody's going to read (it wastes RAM)
Honest tradeoffs
Pros:
- Strong consistency: the cache is never stale after a write
- Ultra-fast reads always (a guaranteed hit)
- You eliminate the cold cache problem for data that goes through write-through
Cons:
- Writes are ~2x slower
- You cache data that may never be read → wasted memory
- If Redis is down at the moment of the write, write-through "breaks" — you have to decide: fail the request or continue without caching?
Write-behind: maximum throughput
How it works
WRITES (fast, async batch):
┌─────────┐
│ Client │
└────┬────┘
│ POST /events
▼
┌─────────────┐
│ FastAPI │
│ Endpoint │
└──────┬──────┘
│
└──── 1. Append to a buffer in Redis (1ms) ─┐
│
▼ │
Return (1ms total) │
│
│ async
▼
┌─────────────────────────┐
│ Background Worker │
│ (runs every 5 sec) │
│ │
│ 1. Read the Redis buffer│
│ 2. Batch INSERT to DB │
│ 3. Clear the buffer │
└──────────┬───────────────┘
│
▼
┌─────────┐
│PostgreSQL│
└─────────┘
The flow:
- A write request arrives
- Push to the Redis buffer (a list or a sorted set) — ~1 ms
- Return to the client immediately
- A periodic worker reads the buffer, does a batch INSERT into PostgreSQL, and clears the buffer
The trick: the client never waits for the DB. A thousand writes per second become 1 INSERT for every 1000 events (in a batch), reducing the load on PostgreSQL by an order of magnitude.
The cost: if Redis goes down between the write and the flush, the unflushed events are lost.
Implementation
Create write_behind.py:
"""
The write-behind pattern: a fast write to a buffer, an async flush to the DB.
"""
import time
import json
import threading
import logging
import redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s: %(message)s')
r = redis.Redis(host='localhost', port=6379, decode_responses=True, socket_timeout=2)
# The buffer's configuration
BUFFER_KEY = "write_buffer:events"
FLUSH_INTERVAL_SECONDS = 5 # flush every 5 seconds
FLUSH_BATCH_SIZE = 100 # or when we reach 100 events
# We simulate an events table in PostgreSQL
fake_events_db = []
def db_insert_events_batch(events: list[dict]):
"""
Simulates a batch INSERT into PostgreSQL.
One transaction with N rows is far more efficient than N separate transactions.
"""
time.sleep(0.05) # 50ms to insert 100 rows
fake_events_db.extend(events)
# ═══════════════════════════════════════════════════════════
# The write API (fast, buffer only)
# ═══════════════════════════════════════════════════════════
def record_event(event: dict):
"""
Write-behind: it only writes to the buffer and returns immediately.
"""
try:
r.rpush(BUFFER_KEY, json.dumps(event))
# If the buffer grows very fast, we could force a flush:
if r.llen(BUFFER_KEY) >= FLUSH_BATCH_SIZE:
flush_buffer()
except RedisError as e:
logger.error(f"Failed to record event: {e}")
# The decision: fail the request or degrade?
# For analytics, better to degrade (the events are nice-to-have)
# For critical data, do NOT use write-behind
# ═══════════════════════════════════════════════════════════
# The async worker (periodic flush)
# ═══════════════════════════════════════════════════════════
def flush_buffer():
"""Empties the buffer and does a batch INSERT into the DB."""
try:
# Read and delete atomically with LRANGE + DEL in a pipeline
pipe = r.pipeline()
pipe.lrange(BUFFER_KEY, 0, -1)
pipe.delete(BUFFER_KEY)
events_raw, _ = pipe.execute()
if not events_raw:
return 0
events = [json.loads(e) for e in events_raw]
db_insert_events_batch(events)
logger.info(f"Flushed {len(events)} events to DB")
return len(events)
except RedisError as e:
logger.error(f"Failed to flush buffer: {e}")
return 0
def background_flusher():
"""A worker thread that flushes periodically."""
logger.info(f"Flusher started (interval={FLUSH_INTERVAL_SECONDS}s)")
while True:
time.sleep(FLUSH_INTERVAL_SECONDS)
try:
flush_buffer()
except Exception as e:
logger.error(f"Flusher error: {e}")
# ═══════════════════════════════════════════════════════════
# Demo
# ═══════════════════════════════════════════════════════════
if __name__ == "__main__":
# Clean up
r.delete(BUFFER_KEY)
fake_events_db.clear()
# Start the flusher in the background
flusher_thread = threading.Thread(target=background_flusher, daemon=True)
flusher_thread.start()
# Simulate 1000 ultra-fast events
print("Simulating 1000 events...")
start = time.time()
for i in range(1000):
record_event({
"user_id": (i % 50) + 1,
"event": "page_view",
"page": f"/products/{(i % 20) + 1}",
"timestamp": time.time(),
})
elapsed = (time.time() - start) * 1000
print(f"Recorded 1000 events in {elapsed:.1f}ms (avg {elapsed/1000:.3f}ms/event)")
# We wait for the flusher
print("\nWaiting for the flusher (10 seconds)...")
time.sleep(10)
print(f"\nEvents in the buffer: {r.llen(BUFFER_KEY)}")
print(f"Events in the DB: {len(fake_events_db)}")
# Force a final flush
flushed = flush_buffer()
print(f"\nForce flush: {flushed} events")
print(f"Final - DB: {len(fake_events_db)}, Buffer: {r.llen(BUFFER_KEY)}")
Output:
2026-04-25 10:00:00 [__main__] INFO: Flusher started (interval=5s)
Simulating 1000 events...
2026-04-25 10:00:01 [__main__] INFO: Flushed 100 events to DB
2026-04-25 10:00:01 [__main__] INFO: Flushed 100 events to DB
... (several flushes auto-triggered by the batch size)
Recorded 1000 events in 350.2ms (avg 0.350ms/event)
Waiting for the flusher (10 seconds)...
2026-04-25 10:00:10 [__main__] INFO: Flushed 0 events to DB
2026-04-25 10:00:15 [__main__] INFO: Flushed 0 events to DB
Events in the buffer: 0
Events in the DB: 1000
Force flush: 0 events
Final - DB: 1000, Buffer: 0
Analysis:
- 1000 events recorded in ~350 ms = 0.35 ms per event (40x faster than writing each one to PostgreSQL)
- The worker flushes automatically when the buffer reaches 100 events
- Eventually consistent: all 1000 events end up in the DB
When to use write-behind
✅ Massive writes:
- Analytics events (page views, clicks, scrolls)
- Audit logs
- Performance metrics
- Like/comment counters (where "losing" 1 like is acceptable)
✅ Performance > strict durability:
- "Eventually" reaching the DB is fine
- You accept that a Redis crash loses some recent events
❌ Do NOT use write-behind when:
- Transactional data (orders, payments, transfers) — losing it is unacceptable
- Legal auditing (logs that regulation requires you to persist)
- Data where immediate consistency matters (bank balances, reservations)
Honest tradeoffs
Pros:
- Ultra-fast writes (the client doesn't wait for the DB)
- Reduces the load on PostgreSQL by an order of magnitude (batch INSERTs)
- Allows write spikes without saturating the DB
Cons:
- The risk of data loss if Redis goes down before the flush (that's the reality — there's no way to avoid it without breaking the pattern's principle)
- Eventually consistent — reads from the DB don't see the events just written
- More complex: you need a worker, monitoring for the buffer size, alerts if it grows without limit
How to mitigate the data loss risk
-
Redis persistence with AOF (Append-Only File):
docker run -d --name redis-dev -p 6379:6379 \ -v redis-data:/data \ redis:7 redis-server --appendonly yes --appendfsync everysecAOF persists every write to disk. If Redis crashes, it recovers everything in the buffer up to the last second. You lose <1 second of events.
-
A more aggressive flush: Lower
FLUSH_INTERVAL_SECONDSandFLUSH_BATCH_SIZE. The tradeoff: more load on the DB but less risk. -
Monitoring: Alert if
LLEN(BUFFER_KEY) > 10000. That means the flusher is falling behind or is down. -
A bypass for critical events: For events that are genuinely important, write straight to the DB (cache-aside or write-through), not to the buffer.
Comparing the 3 patterns
Let's do a real benchmark comparing all 3.
Create benchmark_patterns.py:
"""
A comparative benchmark: cache-aside vs write-through vs write-behind.
"""
import time
import json
import threading
import statistics
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# A shared DB simulation
fake_db = {}
db_writes = []
def db_write(key, value):
time.sleep(0.05)
fake_db[key] = value
db_writes.append((key, value))
def db_read(key):
time.sleep(0.05)
return fake_db.get(key)
# ═══════════════════════════════════════════════════════════
# Cache-aside
# ═══════════════════════════════════════════════════════════
def write_cache_aside(key, value):
"""Cache-aside: it only writes to the DB and invalidates the cache."""
db_write(key, value)
r.delete(f"cache:{key}") # invalidation
def read_cache_aside(key):
cached = r.get(f"cache:{key}")
if cached:
return json.loads(cached)
value = db_read(key)
if value:
r.set(f"cache:{key}", json.dumps(value), ex=300)
return value
# ═══════════════════════════════════════════════════════════
# Write-through
# ═══════════════════════════════════════════════════════════
def write_write_through(key, value):
"""Write-through: it writes to the DB and the cache simultaneously."""
db_write(key, value)
r.set(f"cache:{key}", json.dumps(value), ex=300)
def read_write_through(key):
"""A normal read — always a hit with write-through."""
cached = r.get(f"cache:{key}")
if cached:
return json.loads(cached)
return db_read(key) # rare, only if the TTL expired
# ═══════════════════════════════════════════════════════════
# Write-behind
# ═══════════════════════════════════════════════════════════
WRITE_BEHIND_BUFFER = "wb:buffer"
def write_write_behind(key, value):
"""Write-behind: to the buffer only."""
r.rpush(WRITE_BEHIND_BUFFER, json.dumps({"key": key, "value": value}))
def flush_write_behind():
pipe = r.pipeline()
pipe.lrange(WRITE_BEHIND_BUFFER, 0, -1)
pipe.delete(WRITE_BEHIND_BUFFER)
items_raw, _ = pipe.execute()
items = [json.loads(i) for i in items_raw]
for item in items:
db_write(item["key"], item["value"]) # here we could do a real batch INSERT
# ═══════════════════════════════════════════════════════════
# Benchmark
# ═══════════════════════════════════════════════════════════
def benchmark_writes(name, write_fn, num_writes=100):
fake_db.clear()
db_writes.clear()
r.flushdb()
latencies = []
start_total = time.time()
for i in range(num_writes):
start = time.time()
write_fn(f"item:{i}", {"value": i, "name": f"item-{i}"})
latencies.append((time.time() - start) * 1000)
total_elapsed = (time.time() - start_total) * 1000
return {
"name": name,
"total_ms": total_elapsed,
"avg_ms": statistics.mean(latencies),
"p50_ms": statistics.median(latencies),
"p95_ms": sorted(latencies)[int(len(latencies) * 0.95)],
}
def report(results):
print(f"\n{'Pattern':<20} {'Total':<12} {'Avg':<10} {'p50':<10} {'p95':<10}")
print("-" * 65)
for r in results:
print(f"{r['name']:<20} {r['total_ms']:<12.1f} {r['avg_ms']:<10.2f} {r['p50_ms']:<10.2f} {r['p95_ms']:<10.2f}")
if __name__ == "__main__":
print("WRITE benchmark (100 operations):")
results = []
# Cache-aside writes (DB only)
results.append(benchmark_writes("Cache-aside", write_cache_aside))
# Write-through (DB + cache)
results.append(benchmark_writes("Write-through", write_write_through))
# Write-behind (buffer only)
results.append(benchmark_writes("Write-behind", write_write_behind))
report(results)
# For write-behind, we also measure the flush
print("\nWrite-behind flush:")
flush_start = time.time()
flush_write_behind()
flush_elapsed = (time.time() - flush_start) * 1000
print(f" Flushed 100 events in {flush_elapsed:.1f}ms")
print(f" Total amortized: {(results[2]['total_ms'] + flush_elapsed):.1f}ms")
Expected output:
WRITE benchmark (100 operations):
Pattern Total Avg p50 p95
-----------------------------------------------------------------
Cache-aside 5230.5 52.3 52.0 53.5
Write-through 5341.2 53.4 53.1 55.0
Write-behind 28.4 0.3 0.2 0.8
Write-behind flush:
Flushed 100 events in 5050.2ms
Total amortized: 5078.6ms
Analysis:
| Pattern | Time per write | Notes |
|---|---|---|
| Cache-aside | 52ms | DB only |
| Write-through | 53ms | DB + cache (minimal overhead) |
| Write-behind | 0.3ms | Buffer only (170x faster) |
The trick: Write-behind is 170x faster from the client's perspective, but the flush to the DB takes the same amortized time. The real advantage is that the client doesn't wait for the DB. If you have 1000 concurrent clients, they all return in <1 ms instead of each one blocking for 50 ms.
On reads
# If we pre-populate the cache, reads are similar across all of them:
# - Cache-aside: hit = 1ms, miss = 51ms (it depends on the hit rate)
# - Write-through: hit = 1ms (always)
# - Write-behind: it depends — reads always go to the DB unless there's an additional cache
# The real difference is in the writes.
Choosing per endpoint: the matrix
Here's the decision you'll apply to module 5's capstone project:
| Characteristic | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| Read frequency | High | Any | Any |
| Write frequency | Low-Medium | Low | High |
| Stale tolerance | Fine with a TTL | No | Fine |
| Data loss tolerance | None | None | Acceptable |
| Performance-critical on | Reads | Reads | Writes |
A decision template
Ask yourself, for every endpoint:
1. How many reads vs writes?
- Reads >> Writes → cache-aside or write-through
- Very frequent writes → write-behind
2. Is it acceptable to serve stale data for a few seconds?
- Yes → cache-aside with a TTL
- No → write-through
3. Is it acceptable to lose some writes if Redis goes down?
- Yes (analytics, logs, counters) → write-behind
- No (transactions, balances) → cache-aside or write-through
4. Does immediate consistency affect the UX?
- Yes → write-through
- No → cache-aside with a TTL
5. Are the writes saturating PostgreSQL?
- Yes (>1000 writes/sec) → consider write-behind
- No → cache-aside or write-through
Examples from the Production Cached API (module 5)
| Endpoint | Pattern | Justification |
|---|---|---|
GET /products | Cache-aside, 5 min TTL | Many reads, it barely changes, stale is fine |
GET /products/{id} | Cache-aside, 10 min TTL | Same reasoning |
POST /products (admin) | Cache-aside (invalidate on write) | The change is visible immediately |
GET /user/{id}/profile | Cache-aside with a hash, 30 min TTL | A hash allows granular updates |
PUT /user/{id} | Write-through | Profile changes must be seen immediately (UX) |
POST /products/{id}/like | Write-behind | A counter, high concurrency, losing 1 like is acceptable |
POST /analytics/event | Write-behind | Massive writes, eventually consistent is fine |
GET /accounts/{id}/balance | Write-through | Stale = a critical bug |
POST /payments | Cache-aside (don't cache the writes) | A critical transaction, there's no margin for risk |
GET /settings/global | Cache-aside, 1 hr TTL | It rarely changes, heavily read |
Troubleshooting
Problem 1: Write-through and Redis is down — what do I do?
Cause: Write-through expects to write to the cache. If Redis fails, do you fail the request or let it through?
Solution: It's a business decision. Two options:
# Option 1: Strict (fail if the cache fails)
def update_account_strict(account_id, data):
db_update_account(account_id, data)
try:
r.set(f"account:{account_id}", json.dumps(data), ex=600)
except RedisError:
raise HTTPException(503, "Cache unavailable, retry")
return data
# Option 2: Degraded (continue without caching)
def update_account_degraded(account_id, data):
db_update_account(account_id, data)
try:
r.set(f"account:{account_id}", json.dumps(data), ex=600)
except RedisError as e:
logger.warning(f"Cache update failed: {e}")
# The next read will be a cache miss → it repopulates
return data
For the capstone project, use the "degraded" version — the API works, just more slowly. This is graceful degradation.
Problem 2: The write-behind buffer grows without limit
Cause: The flusher is down or very slow, and the writes keep coming in.
Solution:
-
Monitoring: alert if the buffer > N items
buffer_size = r.llen("write_buffer:events") if buffer_size > 10000: send_alert(f"Write-behind buffer overflow: {buffer_size}") -
Backpressure: reject writes if the buffer is too full
def record_event(event): if r.llen("write_buffer:events") > 50000: raise HTTPException(503, "System overloaded, retry later") r.rpush("write_buffer:events", json.dumps(event)) -
A more aggressive worker: multiple parallel flushers to empty the buffer faster
Problem 3: A race condition in write-through under concurrency
Cause: If two workers update the same account simultaneously, the order of the DB write and the cache write can get mixed up:
Worker A: db_update(balance=1500) → cache_set(1500)
Worker B: db_update(balance=2000) → cache_set(2000)
If the operations interleave:
A: db_update(1500)
B: db_update(2000)
B: cache_set(2000)
A: cache_set(1500) ← the cache is left with the old value!
Solution: Use WATCH + MULTI (a transaction) or invalidate-instead-of-update:
def update_account_safe(account_id, data):
db_update_account(account_id, data)
# Instead of SET, DELETE → the next read regenerates the cache
r.delete(f"account:{account_id}")
The trade-off: the next read will be a miss (~50 ms). But it guarantees consistency.
Problem 4: Write-behind loses events after a Redis crash
Cause: You're using Redis without persistence.
Solution: Enable AOF when you start Redis:
docker run -d --name redis-dev -p 6379:6379 \
-v redis-data:/data \
redis:7 redis-server --appendonly yes --appendfsync everysec
appendfsync everysec flushes to disk once per second. In a crash, you lose <1 sec of events. For critical data, use appendfsync always (every write goes to disk) — slower but safer.
For genuinely critical data, don't use write-behind. Period.
Problem 5: How do I know which pattern an endpoint is using?
Cause: Without documentation, the patterns get mixed and nobody knows what's happening.
Solution: Document it in the code and in the README:
@app.get("/products")
def list_products():
"""
Cache-aside, TTL 300s.
Invalidate on: POST/PUT/DELETE /products.
"""
...
@app.put("/users/{user_id}")
def update_user():
"""
Write-through. The cache is always consistent.
"""
...
@app.post("/analytics/event")
def record_event():
"""
Write-behind. A buffer in Redis, an async flush every 5s.
Acceptable loss: <1 second of events if Redis crashes.
"""
...
This is what you'll deliver as the caching strategy document in module 5.
Exercises
Exercise 1: Basic write-through (Easy)
Implement update_product(product_id, data) with write-through. Use an in-memory "DB." After the update, verify that an immediate read sees the updated data without touching the "DB" (check with a DB read counter).
See solution
import json
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
fake_db = {1: {"id": 1, "name": "Original", "stock": 10}}
db_read_count = 0
def db_get(pid):
global db_read_count
db_read_count += 1
return fake_db.get(pid)
def db_update(pid, data):
if pid in fake_db:
fake_db[pid].update(data)
return fake_db[pid]
return None
def update_product_write_through(pid, data):
updated = db_update(pid, data)
if updated:
r.set(f"product:{pid}", json.dumps(updated), ex=300)
return updated
def get_product(pid):
cached = r.get(f"product:{pid}")
if cached:
return json.loads(cached)
return db_get(pid)
# Test
r.delete("product:1")
db_read_count = 0
# An update with write-through
update_product_write_through(1, {"stock": 5})
print(f"DB reads after update: {db_read_count}") # 0
# An immediate read → cache hit, it doesn't touch the DB
p = get_product(1)
print(f"Product: {p}")
print(f"DB reads after read: {db_read_count}") # 0 (cache hit)
Output:
DB reads after update: 0
Product: {'id': 1, 'name': 'Original', 'stock': 5}
DB reads after read: 0
Explanation: The update wrote to the DB and the cache. The subsequent read came from the cache (it didn't increment db_read_count). That's write-through working: the cache always has the latest value.
Exercise 2: Write-behind with a buffer (Medium)
Implement a likes system with write-behind. Each like(post_id, user_id) should be added to the buffer. After 100 simulated likes, run the flush manually and verify that the "DB" (a dict) has the correct counters.
See solution
import json
import redis
from collections import Counter
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
likes_db = Counter() # post_id → like count
def like_write_behind(post_id, user_id):
r.rpush("likes:buffer", json.dumps({"post_id": post_id, "user_id": user_id}))
def flush_likes():
pipe = r.pipeline()
pipe.lrange("likes:buffer", 0, -1)
pipe.delete("likes:buffer")
items_raw, _ = pipe.execute()
for raw in items_raw:
item = json.loads(raw)
likes_db[item["post_id"]] += 1
return len(items_raw)
# Test
r.delete("likes:buffer")
likes_db.clear()
# Simulate 100 likes (a mix of posts)
import random
for _ in range(100):
like_write_behind(post_id=random.randint(1, 5), user_id=random.randint(1, 50))
print(f"Buffer size: {r.llen('likes:buffer')}") # 100
print(f"DB likes: {dict(likes_db)}") # {} (not flushed yet)
# Flush
flushed = flush_likes()
print(f"\nFlushed {flushed} likes")
print(f"DB likes: {dict(likes_db)}")
print(f"Buffer size: {r.llen('likes:buffer')}") # 0
Example output:
Buffer size: 100
DB likes: {}
Flushed 100 likes
DB likes: {1: 18, 2: 24, 3: 15, 4: 22, 5: 21}
Buffer size: 0
Explanation: The 100 likes accumulate in the Redis buffer (a fast operation, ~0.3 ms each). The flush processes them all together in a batch. If the "DB" were real, you'd do a BATCH INSERT or an UPSERT on the counters — an order of magnitude more efficient than 100 separate UPDATEs.
Exercise 3: Comparing the 3 patterns (Medium-Hard)
Measure the time of 100 operations for all 3 patterns: cache-aside (write), write-through, write-behind. Report the average. Then measure reads on a warm cache. Compare and explain the results.
See solution
See the benchmark_patterns.py script earlier in the capsule. For this exercise, run it and analyze:
# The expected summary:
"""
Pattern Total Avg p50 p95
-----------------------------------------------------------------
Cache-aside 5230.5 52.3 52.0 53.5
Write-through 5341.2 53.4 53.1 55.0
Write-behind 28.4 0.3 0.2 0.8
Reads on a warm cache (all similar):
- Cache-aside (after warming): 1ms avg
- Write-through: 1ms avg (always warm)
- Write-behind: it depends — it needs an additional cache layer
"""
The expected analysis:
-
Writes:
- Cache-aside (52 ms) and write-through (53 ms) are similar — they only differ in whether they update the cache
- Write-behind (0.3 ms) is 170x faster from the client's side, because it doesn't wait for the DB
- Write-behind's total "work" (300 ms for 100 events + 5 s for the background flush) is similar to the others, but spread out over time
-
Reads:
- All similar (~1 ms) when there's a hit
- Write-through has the advantage that there's always a hit
- Cache-aside has the cold cache problem until it fills up
-
When to choose which:
- Reads dominate + stale is fine → cache-aside (simpler)
- Reads dominate + consistency is critical → write-through
- Massive writes, losing some is fine → write-behind
Exercise 4: Write-behind with auto-flush by size (Medium)
Modify the write-behind so it flushes automatically when the buffer reaches 50 items, without waiting for the timer.
See solution
import json
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
events_db = []
def flush_buffer():
pipe = r.pipeline()
pipe.lrange("buffer", 0, -1)
pipe.delete("buffer")
items_raw, _ = pipe.execute()
items = [json.loads(i) for i in items_raw]
events_db.extend(items)
return len(items)
def record_event_with_auto_flush(event, threshold=50):
r.rpush("buffer", json.dumps(event))
# Auto-flush if it exceeds the threshold
if r.llen("buffer") >= threshold:
flushed = flush_buffer()
print(f"Auto-flush triggered: {flushed} events flushed")
# Test
r.delete("buffer")
events_db.clear()
# Simulate 130 events
for i in range(130):
record_event_with_auto_flush({"event": "click", "id": i})
# We'd expect:
# - Auto-flush 1: when it reaches item 50 → flush 50
# - Auto-flush 2: when it reaches item 100 → flush 50
# - 30 are left in the buffer
print(f"\nFinal buffer: {r.llen('buffer')}") # 30
print(f"DB events: {len(events_db)}") # 100
# Force a final flush
flush_buffer()
print(f"After the manual flush - DB: {len(events_db)}, Buffer: {r.llen('buffer')}")
Output:
Auto-flush triggered: 50 events flushed
Auto-flush triggered: 50 events flushed
Final buffer: 30
DB events: 100
After the manual flush - DB: 130, Buffer: 0
Explanation: Auto-flush by size works like a circuit breaker: it guarantees the buffer doesn't grow infinitely even if the periodic flusher is down. Combining it with a periodic flush (every N seconds) and buffer size monitoring is the production-ready pattern.
Exercise 5: Choosing a pattern per endpoint (Medium)
Design the caching strategy for a mini e-commerce API. For each endpoint, decide: cache-aside, write-through, write-behind, or "don't cache." Justify it.
Endpoints:
GET /products(the catalog)GET /products/{id}(the detail)POST /products/{id}/view(records a view for analytics)POST /cart/add(add an item to the cart)GET /cart(view the cart)POST /orders(create an order, requires a stock check + payment)GET /users/{id}/profile(the profile)PUT /users/{id}(update the profile)GET /search?q=...(a search)POST /reviews(create a review)
See solution
| Endpoint | Pattern | Justification |
|---|---|---|
GET /products | Cache-aside 5 min TTL | Frequent reads, it barely changes, stale is fine |
GET /products/{id} | Cache-aside 10 min TTL | Same reasoning, a specific key |
POST /products/{id}/view | Write-behind | Massive analytics, losing 1 view is fine |
POST /cart/add | Write-through (Redis as the source of truth for the cart) | The cart lives in Redis with a TTL + a sync to the DB. The UX requires immediate consistency |
GET /cart | Cache-aside or read straight from Redis | If the cart is in Redis, you don't need another cache |
POST /orders | Don't cache the writes (cache-aside with invalidation) | A critical transaction with payment. Do NOT cache. Only invalidate the products cache (the stock changed) |
GET /users/{id}/profile | Cache-aside with a hash, 30 min TTL | The profile changes occasionally |
PUT /users/{id} | Write-through | The UX requires seeing the change immediately |
GET /search?q=... | Cache-aside 2 min TTL, top queries only | Search has an infinite space; only cache common queries |
POST /reviews | Cache-aside (invalidate the product detail cache) | A new review should invalidate the product's cache |
The key justifications:
-
Why products is cache-aside, not write-through: products change infrequently, mostly from admin panels. A 5-10 min TTL covers 99% of cases.
-
Why views is write-behind: you could have 100,000 views/min on a popular product. Inserting each one into PostgreSQL saturates it. Write-behind allows batch INSERTs every 5 sec.
-
Why the cart is write-through: critical UX. If the user adds an item and the cache doesn't update, they see an "old" cart — a bug.
-
Why orders aren't cached: an atomic transaction with a stock check, payment, and order ID generation. There's no way to cache this. We only invalidate the related caches (products) after creation.
-
Why search is selective:
?q=laptoprepeats a lot → cache it.?q=some_random_unique_querydoesn't repeat → don't cache it.
Summary
In this capsule you learned:
Write-through:
- Writes to the DB and the cache simultaneously
- The cache is always consistent (never stale)
- Ultra-fast reads always (a guaranteed hit)
- Cost: writes are ~2x slower
- Use it for: balances, authorization, data where immediate consistency matters
Write-behind:
- Writes to the Redis buffer (fast)
- An async worker flushes periodically to the DB
- 170x faster on writes (from the client's perspective)
- Cost: the risk of data loss if Redis goes down before the flush
- Use it for: analytics, logs, counters, massive event volumes
Comparing the 3 patterns:
| Pattern | Write speed | Read speed | Consistency | Data loss risk |
|---|---|---|---|---|
| Cache-aside | Fast (DB only) | Fast (after a hit) | Eventual (TTL) | None |
| Write-through | Slow (DB + cache) | Always fast | Strong | None |
| Write-behind | Ultra-fast | Variable | Eventual | Yes, without AOF |
Choosing per endpoint:
- 80% of the reads in a typical API → cache-aside
- Critical data with no tolerance for stale → write-through
- Massive writes with tolerance for losing some → write-behind
Production tips:
- AOF enabled in Redis to mitigate data loss in write-behind
- Buffer size monitoring with alerts
- Backpressure if the buffer grows without limit
- Document each endpoint's pattern (it's what you'll deliver in module 5)
Additional resources
- Microsoft: Write-Through Cache pattern — The trade-offs explained
- AWS: Write-Through and Write-Around — A formal comparison
- Redis: Persistence (RDB + AOF) — How to configure persistence to mitigate data loss
- Reliable Queueing in Redis (Part 1) — Patterns for durable queues
- Eventual Consistency — Werner Vogels (Amazon's CTO) on eventual consistency
- System Design Interview: Write Strategies — A practical comparison with real cases
What's next?
In Capsule 04 you get into caching's hardest topic: TTL strategies and cache invalidation. You'll cover fixed vs sliding TTL, the 3 invalidation strategies (manual, event-driven, version-based), stale-while-revalidate, and how to decide the optimal TTL per type of data. It's the capsule that separates "I use Redis" from "I have a caching strategy."
Keep Redis running. Capsule 04 keeps building on the same workspace.