Why Redis Caching Patterns Matter
Redis is the go-to in-memory cache for high-performance applications, but “throw it in the cache” is rarely a recipe for success at scale. When you move beyond toy projects, picking the right caching pattern is crucial for:
- Minimizing cache misses and database load
- Ensuring consistency between cache and data source
- Controlling cache size, cost, and data freshness
- Supporting different read/write workloads
The wrong pattern can lead to stale data, excessive cache churn, or even outages. The right one can cut latency by 90% or more and keep your infrastructure bills under control. This post digs into real-world Redis caching patterns—with code, not buzzwords—so you can make decisions that stick in production.
Core Redis Caching Patterns
Most production apps use one or more of the following patterns. Each has its place, and the best choice depends on your workload and consistency requirements.
- Cache-Aside (Lazy Loading): Your app queries the cache first, then loads from the database and populates the cache on a miss.
- Write-Through: All writes go through the cache, which synchronously updates the database.
- Write-Behind (Write-Back): Writes hit the cache and are asynchronously flushed to the database.
- Read-Through: The cache fetches data from the backend on a miss, abstracting away the database from your app.
- Cache Prefetching: Data is proactively loaded into the cache before the app requests it.
Let’s break down the most common approaches with production-ready code and explain when you’ll hit edge cases.
Real-World Examples of Redis Cache Patterns
Cache-Aside (Lazy Loading): The Workhorse
import redis
import psycopg2
import json
import time
# pip install redis==4.6.0 psycopg2-binary==2.9.9
redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
db_conn = psycopg2.connect(host='localhost', dbname='prod', user='readonly', password='secret')
def get_user_profile(user_id: int):
cache_key = f"user_profile:{user_id}"
cached = redis_client.get(cache_key)
if cached:
# Cache hit
return json.loads(cached)
# Cache miss: fetch from DB
with db_conn.cursor() as cur:
cur.execute("SELECT id, name, email, last_login FROM users WHERE id=%s", (user_id,))
row = cur.fetchone()
if not row:
return None # Not found
user_profile = {
"id": row[0],
"name": row[1],
"email": row[2],
"last_login": row[3].isoformat()
}
# Store in cache with 10 min TTL
redis_client.setex(cache_key, 600, json.dumps(user_profile))
return user_profile
# Usage:
profile = get_user_profile(123)
print(profile)
# Output (on first call): pulls from DB, then caches result
# Output (on subsequent calls): returns cached JSON
What’s happening? This is the classic cache-aside pattern. Your application code is responsible for cache population and invalidation. It’s simple, fast for reads, and won’t bloat the cache with unused data.
Why use it?
- Best for read-heavy workloads where occasional stale reads are OK
- Cache only gets “hot” data (what’s actually requested)
Production pitfalls:
- If data changes in the DB, cache can become stale unless you evict or update on writes.
- Race conditions: two concurrent cache misses can result in duplicate DB queries. Use
SETNXor distributed locks to avoid “cache stampede.”
Write-Through: Always Consistent, but Slower Writes
def update_user_profile(user_id: int, new_profile: dict):
cache_key = f"user_profile:{user_id}"
# Write to DB first
with db_conn.cursor() as cur:
cur.execute(
"UPDATE users SET name=%s, email=%s, last_login=%s WHERE id=%s",
(new_profile["name"], new_profile["email"], new_profile["last_login"], user_id)
)
db_conn.commit()
# Then update cache synchronously
redis_client.setex(cache_key, 600, json.dumps(new_profile))
return True
# Usage:
update_user_profile(123, {
"name": "Alice",
"email": "[email protected]",
"last_login": time.strftime("%Y-%m-%dT%H:%M:%S")
})
What’s happening? Every write hits both the database and the cache in a single transaction. This ensures the cache is never stale after a write.
Why use it?
- Strong consistency: reads never see stale data after a write
- Reduced cache misses for recently modified data
Production pitfalls:
- Write latency increases (must write to cache and DB synchronously)
- May cache cold data that’s rarely read, increasing cost
Write-Behind (Write-Back): Async for High Write Throughput
# Simplified illustration of write-behind using a background flusher.
# In production, use a task queue (like Celery or Sidekiq) for reliability.
from threading import Thread
import queue
write_queue = queue.Queue()
def write_user_profile_async(user_id: int, new_profile: dict):
cache_key = f"user_profile:{user_id}"
# Update cache immediately
redis_client.setex(cache_key, 600, json.dumps(new_profile))
# Queue DB write
write_queue.put((user_id, new_profile))
def background_db_flusher():
while True:
user_id, profile = write_queue.get()
try:
with db_conn.cursor() as cur:
cur.execute(
"UPDATE users SET name=%s, email=%s, last_login=%s WHERE id=%s",
(profile["name"], profile["email"], profile["last_login"], user_id)
)
db_conn.commit()
except Exception as e:
print("Write-back failed:", e)
# In production: retry, dead-letter, or alert
write_queue.task_done()
# Start background thread (in real apps, use a dedicated service)
flusher_thread = Thread(target=background_db_flusher, daemon=True)
flusher_thread.start()
# Usage:
write_user_profile_async(123, {
"name": "Bob",
"email": "[email protected]",
"last_login": time.strftime("%Y-%m-%dT%H:%M:%S")
})
What’s happening? Writes return quickly after updating the cache; the database update happens on a background thread.
Why use it?
- Low write latency (good for high-throughput ingestion)
- Can batch DB writes for efficiency
Production pitfalls:
- Risk of data loss if the cache node crashes before persisting to DB
- Eventual consistency: stale reads possible until DB is updated
- Requires robust failure handling (retries, dead-letter queues)
Choosing and Tuning Cache Patterns in Production
Selecting a pattern is only the first step. In production, small decisions have big impact:
- Key design: Use descriptive, namespaced keys (e.g.,
user_profile:123), not ambiguous or non-unique strings. - Value size: Redis performance drops with very large values. Keep values under 100KB if possible (see discussion).
- Expiration: Set sensible TTLs. Too short and you hammer the DB; too long and stale data persists.
- Eviction policy: Choose between LRU, LFU, or TTL-based eviction depending on access patterns.
- Pipelining: Use Redis pipelining for batch operations to minimize roundtrips (critical at scale).
- Region placement: Keep your app and Redis in the same region to minimize latency (Microsoft docs).
Example: Setting a sensible TTL for user sessions
# Set session data with a 2-hour expiry
redis_client.setex(f"session:{session_id}", 7200, json.dumps(session_data))
If you don’t set expiry, you risk memory bloat and unpredictable eviction under load. In production, always set a TTL unless you have a strong reason not to.
Advanced Patterns and Pitfalls
Once your app scales or you need stricter data guarantees, you’ll encounter advanced scenarios:
- Cache stampede (thundering herd): Many requests for the same missing key can overwhelm your DB. Use a distributed lock or
SETNXto let only one request repopulate the cache. - Read-through cache: With libraries like Redisson or Spring Cache, the cache itself fetches from your backend, abstracting away cache misses. This is less common in Python but prevalent in JVM ecosystems.
- Cache prefetching: For time-series or reporting data, proactively load hot data into cache before it’s needed. Requires a background job or change-data-capture hook.
- Hybrid patterns: You can combine write-through for “hot” objects and cache-aside for the rest, balancing consistency and cost.
Example: Cache stampede protection with SETNX
def get_with_stampede_protection(key, fetch_func, ttl=600):
cache_val = redis_client.get(key)
if cache_val is not None:
return json.loads(cache_val)
# Try to acquire a lock
lock = redis_client.set(f"lock:{key}", "1", nx=True, ex=5)
if lock:
# We won the race, repopulate cache
data = fetch_func()
redis_client.setex(key, ttl, json.dumps(data))
redis_client.delete(f"lock:{key}")
return data
else:
# Wait for the lock holder to finish
time.sleep(0.1)
return get_with_stampede_protection(key, fetch_func, ttl)
This helps prevent hundreds of concurrent cache misses from stampeding your database.
Pattern Comparison Table
| Pattern | Read Consistency | Write Latency | Cache Size | When to Use | Common Pitfalls |
|---|---|---|---|---|---|
| Cache-Aside | Eventual | Low | Hot data only | Read-heavy apps, staleness OK | Stale cache, stampede on miss |
| Write-Through | Strong | High | All modified data | Strict consistency, frequent reads & writes | Write latency, caches cold data |
| Write-Behind | Eventual | Low | All modified data | High write throughput | Data loss on crash, eventual consistency |
| Read-Through | Configurable | Depends on backend | Hot data only | Framework-driven caching | Opaque DB errors, less control |
| Cache Prefetching | Strong (for prefetched data) | Low (if prefetch is timely) | Depends on prefetch coverage | Predictable, periodic workloads | Stale if data changes unexpectedly |
Key Takeaways:
- Cache-aside is the default for most apps, but write-through and write-behind are essential for stricter consistency or massive write loads.
- Always set TTLs and use descriptive keys to avoid memory leaks and naming collisions.
- Protect against cache stampedes in read-heavy systems with locks or request coalescing.
- Start simple, but monitor cache hit rates, memory growth, and latency — tune patterns as your app evolves.
- Redis is powerful, but the right caching pattern is what makes the difference between “fast sometimes” and “fast always.”
For a deeper dive, see the official Redis caching documentation and the AWS Caching Strategies whitepaper.
If you’re designing for scale, always test with realistic workloads—synthetic benchmarks miss race conditions and bottlenecks that only show up in real traffic. Redis patterns are simple to describe, but the trade-offs are real and only surface under pressure.



