Module 2: Caching Patterns and TTL
Introduction to Module 2: Caching Patterns and TTL
Overview
You closed module 1 knowing how to operate Redis: you install it with Docker, navigate the CLI, handle the 5 data types with judgment, and connect from Python with redis-py. But knowing how to operate Redis isn't knowing how to cache. Caching is strategy, not implementation. And the strategy comes with real tradeoffs you have to decide on, not recipes to copy.
This module is the heart of the guide. You'll learn the 3 fundamental caching patterns — cache-aside, write-through, and write-behind — with selection criteria, not as a Wikipedia list. Each pattern solves a specific problem and has a cost. Cache-aside has the "cold cache problem" (the first request is always slow). Write-through slows writes down but guarantees consistency. Write-behind is blazingly fast on writes but can lose data if Redis goes down before the flush. There's no "best" pattern — there are patterns appropriate for specific situations.
And then there's caching's hardest problem: invalidation. Phil Karlton, a Netscape engineer, put it in a famous line you'll hear many times: "There are only two hard things in Computer Science: cache invalidation and naming things." Naming things is hard because it could be any name. Cache invalidation is hard because when the data changes in PostgreSQL, your cache in Redis doesn't find out automatically — and serving stale data can be anything from a UX annoyance to a critical bug (imagine showing the wrong bank balance). You'll learn 3 invalidation strategies: manual, event-driven (a preview of module 4's Pub/Sub), and version-based.
Where are we in the guide?
You're in Module 2 of 5 of the Redis & Caching Strategies guide:
Module 1: Redis Fundamentals ✅ (completed)
→ Installation, CLI, the 5 data types, redis-py
Module 2: Caching Patterns & TTL ← YOU ARE HERE
→ Cache-aside, write-through, write-behind, TTL strategies, invalidation
Module 3: Rate Limiting & Session Storage
→ Token bucket, sliding window, Redis sessions
Module 4: Pub/Sub & FastAPI Integration
→ Pub/Sub, redis.asyncio, dependency injection, middleware
Module 5: Project — Production Cached API
→ The full stack with a documented caching strategy
How this module builds on module 1
Each caching pattern leans on a specific data type:
- Cache-aside uses strings (caching responses as JSON) or hashes (caching objects with editable fields)
- Write-through uses strings or hashes — the difference is operational, not structural
- Write-behind uses lists as a buffer of pending writes (then flushes to the DB)
- TTL applies to any data type with
EXPIREorSET ... EX seconds - Event-driven invalidation previews module 4's Pub/Sub
If the 5 data types are clear to you, the patterns will flow. If not, go back to module 1 before continuing.
The central problem: cache invalidation
Before the patterns, it's worth understanding why caching is hard — because any superficial solution will hand you bugs in production.
The simple scenario that seems to work
You have an endpoint that lists products:
@app.get("/products")
def list_products():
products = db.query(Product).all()
return [p.to_dict() for p in products]
Every request runs a SELECT against PostgreSQL. With 100 concurrent users, PostgreSQL handles 100 queries per second. It works. But at 10am on Monday when 5,000 concurrent users show up, PostgreSQL saturates. You need cache.
The naive solution:
@app.get("/products")
def list_products():
cached = r.get("cache:products")
if cached:
return json.loads(cached)
products = db.query(Product).all()
serialized = [p.to_dict() for p in products]
r.set("cache:products", json.dumps(serialized))
return serialized
PostgreSQL only gets hit once. The next 4,999 requests read from Redis in <1 ms. Performance solved.
Where it breaks (the hidden problem)
An admin adds a new product:
@app.post("/admin/products")
def create_product(data: ProductCreate):
product = Product(**data.dict())
db.add(product)
db.commit()
return product
PostgreSQL now has 51 products. But cache:products in Redis still holds the original 50. The 5,000 concurrent users keep seeing the old list for... how long? Forever, until someone deletes the key by hand.
This is the cache invalidation problem: when the data changes in the source of truth (PostgreSQL), the cache doesn't find out. And every strategy for solving it has tradeoffs.
The 3 invalidation strategies
-
TTL (time-to-live): "The cache only lives for 5 minutes. After that it deletes itself and the next request regenerates it." Simple, but users see stale data for up to 5 minutes.
-
Manual (delete on write): "When I add/edit/delete a product, I also delete
cache:productsexplicitly." Immediate, but it takes discipline — if you forget ther.delete()in some endpoint, you get bugs. -
Event-driven (Pub/Sub): "When a product changes, I publish a
product_changedevent and subscribers invalidate the related caches." Elegant, scales to multiple services, but more complex to implement.
This module covers all three. You'll come out with the judgment to choose which one to use in each situation.
The 3 caching patterns in 30 seconds
Cache-aside (lazy loading)
Request → Check Redis → Miss → Query PostgreSQL → Store in Redis → Return
→ Check Redis → Hit → Return from Redis
- ✅ You only cache what's requested (memory-efficient)
- ✅ If Redis goes down, the app keeps working (slower, but alive)
- ❌ The first request is always slow (cold cache)
- ❌ The data can be out of date (it needs a TTL or manual invalidation)
Use it when: 80% of your reads. It's the default pattern.
Write-through
Write Request → Update PostgreSQL → Update Redis (synchronously) → Return
Read Request → Check Redis → Hit → Return
- ✅ The cache is always consistent with the DB
- ✅ Ultra-fast reads (always a hit)
- ❌ Writes are slower (it writes in 2 places)
- ❌ You cache EVERYTHING, even data nobody's going to read (it can eat RAM)
Use it when: Critical data where you can't serve anything stale (balances, authorization, critical configuration).
Write-behind (write-back)
Write Request → Update Redis (fast) → Return
→ [async batch] → Flush to PostgreSQL
- ✅ Ultra-fast writes (Redis only, it doesn't wait for the DB)
- ✅ Reduces the load on PostgreSQL (batch writes)
- ❌ If Redis goes down before the flush, you lose data
- ❌ More complex (you need a worker to do the flush)
Use it when: Analytics, logs, metrics — data where losing some events is acceptable.
Visual comparison
| Aspect | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| Read speed | Fast (after the first miss) | Always fast | Fast (for whatever's cached) |
| Write speed | Normal (it only writes to the DB) | Slow (it writes to the DB + cache) | Ultra-fast (Redis only) |
| Consistency | Eventual (it depends on the TTL/invalidation) | Strong | Eventual (until the flush) |
| Risk of losing data | None | None | Yes, if Redis goes down |
| Complexity | Low | Medium | High |
| Typical case | Read APIs | Critical data | Analytics, logs |
What you'll learn in this module
By the end of the 5 capsules:
Cache-aside (capsule 02)
- Implementing the pattern step by step with
redis-py - Measuring hit rate and latency with/without cache (real numbers, not intuition)
- Understanding the "cold cache problem" and when it matters
- Strategies for "warming" the cache when the app starts
Write-through and Write-behind (capsule 03)
- Implementing write-through for data that requires strong consistency
- Implementing write-behind with an async worker that flushes periodically
- Comparing the throughput of the 3 patterns in a controlled benchmark
- Deciding with judgment: does this endpoint need write-through or write-behind?
TTL strategies and cache invalidation (capsule 04)
- Fixed TTL vs sliding TTL (which renews with every access)
- Designing a TTL per data type (5 min for searches, 1 hr for catalogs, 24 hr for configuration)
- Manual invalidation with
DELETE on write - Version-based invalidation (a cache key with a version number)
- Stale-while-revalidate: serving the expired value while it regenerates in the background
Cache stampede + the Caching Pattern Lab mini-project (capsule 05)
- The cache stampede problem: 1000 concurrent requests when a popular value expires
- Solutions: locking with
SETNX, probabilistic early expiration - Mini-project: implementing the 3 patterns over the same dataset and comparing hit rates
Connection with the capstone project
Module 5's project (Production Cached API) makes caching strategy decisions based on what you learn here:
| Endpoint in the final project | Assigned pattern | Reason |
|---|---|---|
GET /products (public listing) | Cache-aside with a 5 min TTL | Heavily read, the data barely changes |
GET /products/{id} | Cache-aside with a 10 min TTL | Same reasoning, a specific key |
GET /user/{id}/profile | Cache-aside with a hash + 30 min TTL | A hash allows granular updates without rewriting everything |
POST /products (admin) | Write-through | The change is visible immediately, nothing stale |
POST /analytics/event | Write-behind | Batch writes to an events table, performance is critical |
GET /settings/global | Cache-aside with a 1 hr TTL | It rarely changes |
The project isn't "add cache to everything." It's designing a caching strategy where each endpoint has its pattern, its TTL, and its invalidation strategy documented. This module gives you the judgment to make those decisions.
This module's 5 capsules
Capsule 01: Module introduction (you are here)
→ Context, the invalidation problem, a preview of the 3 patterns
Capsule 02: Cache-aside (lazy loading)
→ The most common pattern, hit rate, cold cache, implementing it with redis-py
Capsule 03: Write-through and Write-behind
→ Consistency vs performance, when to use each one, a comparison
Capsule 04: TTL strategies and cache invalidation
→ Fixed/sliding TTL, manual/event-driven/version-based invalidation, stale-while-revalidate
Capsule 05: Cache stampede + the Caching Pattern Lab mini-project
→ Locking, probabilistic expiration, a benchmark of the 3 patterns
Estimated time: 2.0-2.5 hours of active study (reading + running code + completing the mini-project).
What you will NOT learn in this module
These topics are intentionally left out:
- ❌ CDN caching (Cloudflare, Fastly) — a different layer of the stack, outside Redis's scope
- ❌ HTTP caching headers (Cache-Control, ETag) — relevant but a different layer; module 4's capsule 04 covers it (middleware)
- ❌ Application-level caching with
functools.lru_cache— it lives inside a single process and isn't shared between workers; it doesn't solve the problem Redis does - ❌ Redis Cluster for cache sharding — outside the scope of the whole guide
- ❌ Client-side caching (browsers, mobile apps) — a frontend concern
- ❌ Fragment caching (parts of pages in SSR) — a very specific case in frameworks like Rails
If after this guide you need to cover any of these, the official documentation and the resources at the end have references.
Prerequisites to get started
Software (you should have this from module 1)
- ✅ Redis running in Docker:
docker ps | grep redis - ✅ Python 3.10+ with an active virtual environment
- ✅
redis-pyinstalled in the venv
Creating the module 2 workspace
mkdir -p ~/projects/redis-guide/module-02-patterns
cd ~/projects/redis-guide/module-02-patterns
python -m venv .venv
source .venv/bin/activate
pip install redis sqlalchemy psycopg2-binary
Assumed prior knowledge
From module 1:
- ✅ Strings:
SET,GET,EXPIRE,SET ... EX seconds - ✅ Hashes:
HSET,HGET,HGETALL - ✅ TTL and expiration
From the path:
- ✅ Basic FastAPI (Guide #6)
- ✅ PostgreSQL/SQLAlchemy concepts (Guide #8) — even though #8 is incomplete, we assume basic familiarity with SELECT queries
⚠️ A note about PostgreSQL: Since guide #8 is still in development, in this module we'll simulate PostgreSQL with an in-memory dict or SQLAlchemy with SQLite. The idea is to teach caching patterns with PostgreSQL as a concept, without you missing any skills if you haven't finished #8 yet.
This module's teaching philosophy
Three principles:
1. Real tradeoffs, not recipes
Every pattern has a cost. You're not going to memorize "when to use cache-aside" — you'll come out understanding WHY that pattern has a cold cache problem and WHEN that problem actually matters. If you later say "I'm going to use cache-aside here," you'll know which downside you're accepting.
2. Measurement, not intuition
Hit rate, miss rate, latency with/without cache. These are real metrics you'll measure in code. Intuition about "how much a cache improves things" is notoriously bad. Benchmarks reveal surprises: sometimes the cache improves things 10x, sometimes only 1.5x, sometimes it doesn't help at all.
3. Production, not toy examples
The examples aren't "suppose you cache a list of Pokémon." They're the same use cases as module 5's capstone project: a product catalog, a user profile, search results. You'll write code you could put into production tomorrow.
How to measure your caching strategy's success
Before implementing anything, get to know the 3 fundamental metrics:
Hit rate
hit rate = hits / (hits + misses)
- < 50%: the cache is poorly designed or the TTL is too low
- 50-80%: acceptable, but there's room for improvement
- 80-95%: well designed, an effective cache
-
95%: you may be caching things that never change (check the TTL)
Latency with/without cache
Measure the difference between:
- The response time when there's a miss (it hits PostgreSQL)
- The response time when there's a hit (it reads from Redis)
If the difference is <50 ms, the cache adds little. If it's >500 ms, the cache is transformational.
Memory usage
Redis is RAM. Caching 1GB of data costs proportionally. If your hit rate is 30% but you're using 5GB of RAM, you're probably caching too much or the TTLs are too long. Measure it with INFO memory.
You'll calculate these three metrics in real code in capsules 02 and 05.
Resources to get started
Official documentation
- Redis: Caching Strategies — The official page with the 3 patterns
- Redis: When to use which Redis data type — Choosing a data type by use case
- redis-py docs — The client we'll use in this module
To understand caching from another angle
- AWS: Caching Best Practices — Technology-independent, it explains the patterns in the abstract
- Microsoft Cloud Design Patterns: Cache-Aside — The pattern documented formally
- System Design Primer: Caching — An overview of caching strategies at the system level
Recommended reading
- Phil Karlton on Cache Invalidation — The origin of the famous line, with historical context
Before moving on to capsule 02
Take 5 minutes to get your workspace ready:
-
Verify Redis:
docker ps | grep redis redis-cli ping # it should respond PONG -
Create the module's workspace:
mkdir -p ~/projects/redis-guide/module-02-patterns cd ~/projects/redis-guide/module-02-patterns python -m venv .venv source .venv/bin/activate pip install redis fastapi uvicorn -
Clean up Redis (optional but recommended so you start fresh):
redis-cli FLUSHDB
Once these three steps are done, you start with cache-aside.
Summary
In this capsule you understood:
- Caching is strategy, not implementation. Every pattern has real tradeoffs the developer has to decide on consciously
- The central problem is cache invalidation: when PostgreSQL changes, Redis doesn't find out automatically. 3 strategies to solve it: TTL, manual delete on write, event-driven
- The 3 patterns in one sentence each:
- Cache-aside: "Read from cache; on a miss, query the DB and store it in cache"
- Write-through: "Always write to the DB and the cache simultaneously"
- Write-behind: "Write to the cache; flush to the DB in an async batch"
- There's no best pattern: cache-aside for 80% of reads, write-through for critical data, write-behind for analytics
- 3 metrics for measuring success: hit rate (>80% is good), latency with/without cache, memory usage
- The module's philosophy: real tradeoffs, measurement instead of intuition, production examples
In capsule 02 you implement cache-aside step by step with redis-py over a real use case (a product catalog), measure hit rate and latency, and experiment with TTL to see the behavior live.
What's next?
Capsule 02: Cache-aside (lazy loading) — The most common pattern. You'll implement it from scratch, see the "cold cache problem" in action, measure hit rate, and solve the first real case: caching GET /products with a TTL.
If your workspace is ready, let's go.