Redis Caching Patterns: Cache-Aside, Write-Through, and Beyond in 2026
A practical guide to Redis caching strategies: cache-aside, write-through, write-behind, TTL management, cache stampede prevention, and distributed caching patterns.
Caching is one of the most impactful optimizations you can make in a modern backend system. When implemented correctly, Redis caching can reduce database load by orders of magnitude, cut response times from hundreds of milliseconds to sub-5ms, and scale horizontally without architectural changes. When implemented poorly, it introduces stale data, memory bloat, and subtle inconsistencies that are fiendishly difficult to debug.
This guide covers the five core Redis caching patterns — cache-aside, write-through, write-behind, TTL management, and cache stampede prevention — along with Redis Cluster sharding and the most common pitfalls. Each section includes working code, real-world benchmarks, and decision guidance so you can choose the right strategy for your specific workload.
Why Redis Caching Matters in 2026
The case for Redis caching has only grown stronger. A 2026 benchmark on Apple Silicon using Axum + Redis achieved 27,309 requests per second (RPS) with an average latency of 4.58ms when serving entirely from cache (dasroot.net, February 2026). That’s three orders of magnitude faster than a typical database query hitting a cold disk.
The math on cache hit rates is equally compelling. A well-architected Redis caching strategy can achieve 95%+ cache hit rates while maintaining efficient memory utilization (Medium, June 2025). At that hit rate, the vast majority of your read traffic never touches your primary database at all.
Redis also integrates deeply with the modern cloud-native stack. Azure Cache for Redis offers clustering that scales throughput roughly linearly as you add shards, making it viable to handle very high-throughput workloads without sacrificing sub-millisecond latency on cache hits (Microsoft Learn).
But the benefits only materialize if you choose the right pattern for your access pattern. Let’s break each one down.
Cache-Aside (Lazy Loading)
How It Works
Cache-aside is the most common caching pattern. The application checks the cache first; if the data is present (a cache hit), it’s returned immediately. If not (a cache miss), the application fetches from the database, stores the result in Redis, and then returns it.
Application Redis Database
| | |
|-- GET user:42 ---------->| |
| | |
|<-- (cache miss) ---------| |
| | |
|-- GET user:42 ------------------------------------------------>|
|<-- user:42 data ---------| |
| | |
|-- SET user:42 <data> --->| |
| | |
|<-- OK -------------------| |
The cache is populated on-demand, which is why this pattern is also called lazy loading.
Implementation
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
def get_user(user_id: str) -> dict | None:
"""
Cache-aside pattern: check cache first, load from DB on miss.
"""
cache_key = f"user:{user_id}"
# Step 1: Try the cache
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Step 2: Cache miss — load from database
user = db_load_user(user_id) # your DB query here
if user:
# Step 3: Populate the cache (with TTL to prevent stale data)
r.setex(cache_key, ttl=300, value=json.dumps(user))
return user
Pros
- Only caches what’s actually read. No wasted memory on data nobody accesses.
- Simple to implement and debug. The cache is purely a read accelerator; the database is always the source of truth.
- Cache failure is isolated. If Redis goes down, the application falls back to the database seamlessly.
- Great for read-heavy workloads with moderate write frequency.
Cons
- First request always misses. Every cache key starts cold, which can cause latency spikes on new deployments or after cache flushes.
- Cache stampede risk. When a popular key expires, many concurrent requests can all miss and hammer the database simultaneously (more on this in the Cache Stampede Prevention section).
- Stale data window. Between a database write and the next cache miss, the application serves stale cached data.
When to Use It
Use cache-aside for read-heavy workloads where data freshness can tolerate a window of staleness — product catalogs, user profiles, content listings, leaderboards, and session data are all natural fits.
Write-Through (Synchronous Cache Update)
How It Works
Write-through ensures the cache is always consistent with the database. On every write, the application writes to the database and immediately writes to the cache. There is no window where the cache holds stale data after a write.
Application Redis Database
| | |
|-- SET user:42 <data> --->| |
| |-- SET user:42 <data> --->|
|<-- OK -------------------| |
| |<-- OK -------------------|
Both operations happen synchronously before the write is considered complete.
Implementation
def create_or_update_user(user_id: str, data: dict) -> None:
"""
Write-through: update cache and database synchronously.
"""
cache_key = f"user:{user_id}"
serialized = json.dumps(data)
# Write to database first (source of truth)
db_save_user(user_id, data)
# Immediately update cache
r.setex(cache_key, ttl=300, value=serialized)
For an atomic multi-step flow, use a Redis transaction:
def create_or_update_user_atomic(user_id: str, data: dict) -> None:
"""
Write-through with Redis transaction (MULTI/EXEC).
"""
cache_key = f"user:{user_id}"
serialized = json.dumps(data)
pipe = r.pipeline()
pipe.setex(cache_key, ttl=300, value=serialized)
# Assuming db_save_user returns a command compatible with pipe.execute()
# In practice, you'd run DB and cache ops in a try/except with rollback
pipe.execute()
db_save_user(user_id, data)
Pros
- Strong consistency. The cache never holds stale data after a write.
- Reads are always fast post-write. Subsequent reads are served from the cache without a cache-miss penalty.
- Predictable behavior. Developers can reason about cache state with certainty.
Cons
- Write latency increases. Every write now involves two round trips (cache + database), adding latency.
- Cache memory pressure. Every write populates the cache, even for data that is rarely read afterward.
- Double write failure risk. If the database write succeeds but the cache write fails (or vice versa), you can end up with an inconsistent state without careful error handling.
- Write throughput limited. Not suitable for high-write-throughput workloads.
When to Use It
Write-through is best for write-once, read-many scenarios where data must be immediately consistent after each write. Configuration data, reference tables, and critical counters are good candidates. Avoid it for high-frequency write workloads.
Write-Behind (Async Write-Behind / Write-Behind Caching)
How It Works
Write-behind (also called write-back or async cache update) flips the consistency model. Writes go to the cache first and return immediately to the client. The database is updated asynchronously, typically via a background worker or message queue.
Application Redis Database
| | |
|-- SET user:42 <data> --->| |
|<-- OK -------------------| |
| | [async worker] |
| |-- UPDATE user:42 ------->|
| |<-- OK -------------------|
This decouples write latency from database write latency entirely.
Implementation
import threading
import queue
write_queue = queue.Queue()
def write_user_async(user_id: str, data: dict) -> None:
"""
Write-behind: write to cache immediately, queue DB update.
"""
cache_key = f"user:{user_id}"
r.setex(cache_key, ttl=300, value=json.dumps(data))
write_queue.put(("user", user_id, data)) # enqueue for async DB write
def background_db_writer():
"""
Background worker that drains the write queue and flushes to the database.
"""
while True:
try:
table, record_id, data = write_queue.get(timeout=5)
db_save_user(record_id, data)
write_queue.task_done()
except queue.Empty:
continue
# Start the background writer thread
writer_thread = threading.Thread(target=background_db_writer, daemon=True)
writer_thread.start()
For production systems, replace the Python queue with a durable message broker (Redis Streams, RabbitMQ, Kafka, or AWS SQS) to survive process restarts.
Pros
- Extremely low write latency. The client gets an instant acknowledgment after the cache write.
- Massively improved write throughput. The database is never a bottleneck for writes.
- Burst handling. Write bursts are smoothed out by the queue, protecting the database from overload.
Cons
- Temporary data loss risk. If Redis crashes before the async write completes, the update is lost unless you’ve replicated the pending writes.
- Complex failure recovery. Reconstructing the set of pending writes after a crash requires a durable write-ahead log or equivalent.
- Stale reads across nodes. If you run multiple application instances with local caches, a write-behind on one instance may not be immediately visible on others.
- Debugging difficulty. The asynchronous nature makes it harder to trace the exact moment a database update occurred.
When to Use It
Write-behind is ideal for analytics, logging, metrics aggregation, and high-frequency event streams where occasional data loss is acceptable and write throughput is the primary concern. It is not suitable for financial transactions, inventory management, or any domain requiring strong consistency.
TTL and Expiration Strategies
Regardless of which write pattern you use, every cached entry needs an expiration strategy. TTL (Time-To-Live) is Redis’s primary mechanism for this.
Setting TTL
# Set a key with a 5-minute TTL
r.setex("user:42", ttl=300, value=json.dumps(user_data))
# Set a TTL on an existing key
r.expire("user:42", 300)
# Set a TTL only if the key doesn't already have one (avoids overwriting shorter TTLs)
r.setnx("user:42", json.dumps(user_data))
r.expire("user:42", 300) # use SET with NX in a transaction for atomicity
TTL Tiering for Different Data Types
Not all data is equal. A practical TTL strategy tiered by data volatility:
| Data Type | Suggested TTL | Rationale |
|---|---|---|
| User sessions | 24 hours | Sessions expire naturally; short TTL limits memory growth |
| Product catalog | 1–4 hours | Changes infrequently but must reflect updates eventually |
| Real-time counters | 60 seconds | High volatility, must stay fresh |
| ML inference results | 24 hours | Expensive compute, cheap storage |
| API rate limit counters | 60 seconds | Reset every minute automatically |
Eviction Policies
When Redis reaches maxmemory, it evicts keys according to the configured policy:
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
Available policies include:
noeviction— Return an error on write; don’t evict anything. Safe but can cause writes to fail under pressure.allkeys-lru— Evict the least recently used keys across all databases. The most common choice for cache workloads.allkeys-lfu— Evict the least frequently used keys. Better than LRU when access patterns follow Zipfian distributions (a few keys are orders of magnitude more popular).volatile-lru/volatile-lfu— Only evict keys with anEXPIREset, within the LRU/LFU model.volatile-ttl— Evict keys with the shortest TTL remaining.
Redis 7.2 introduced LFU with decay, which degrades access counts over time so recently hot keys that have cooled down can be evicted before older warm keys (dasroot.net, February 2026).
Lazy Expiration
Redis doesn’t scan keys to expire them proactively. Instead, when you access a key, Redis checks if its TTL has passed and returns nothing if so. This means expired keys consume memory until they’re accessed or until Redis runs a background active-expire cycle (which runs incrementally to avoid CPU spikes).
Practical implication: Don’t rely on TTL alone for time-sensitive invalidation in high-throughput systems. Pair TTL with an active invalidation mechanism (publish/subscribe, keyspace notifications, or a versioned cache key scheme).
Cache Versioning with TTL
To handle the stale-read window in cache-aside without sacrificing performance, use cache versioning:
def get_user_versioned(user_id: str) -> dict | None:
"""
Cache-aside with versioned keys to enable active invalidation.
"""
current_version = db_get_user_version(user_id) # returns the DB row version/timestamp
cache_key = f"user:{user_id}:v{current_version}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db_load_user(user_id)
if user:
r.setex(cache_key, ttl=3600, value=json.dumps(user))
# Also set a fallback key without version for cache-aside reads
r.setex(f"user:{user_id}:latest", ttl=300, value=json.dumps(user))
return user
When the user is updated, increment the version number in the database. The next read fetches the new version key, and the old version key eventually expires.
Cache Stampede Prevention
The cache stampede (also called thundering herd or dog-piling) occurs when a popular cache key expires or is evicted, and thousands of concurrent requests all miss, all hit the database simultaneously, and all try to repopulate the cache — potentially overwhelming your database before any one of them finishes writing the cache entry.
Probabilistic Early Expiration (PER)
Rather than waiting for the TTL to fully expire, Probabilistic Early Expiration (PER) probabilistically refreshes cache entries before they expire, based on remaining TTL and a configurable probability factor.
import hashlib
import random
def get_with_per(cache_key: str, ttl: int, probability_factor: int = 100) -> str | None:
"""
Probabilistic Early Expiration: refresh cache before TTL expires
to prevent stampedes on popular keys.
"""
cached = r.get(cache_key)
if not cached:
# Cache miss — use a lock to prevent stampede
return get_with_lock(cache_key, ttl)
# Check remaining TTL
remaining_ttl = r.ttl(cache_key)
if remaining_ttl < 0:
return get_with_lock(cache_key, ttl)
# PER: compute probability of early refresh
# Higher probability as TTL gets lower
probability = (ttl - remaining_ttl) / ttl
if random.random() < probability / probability_factor:
# Async refresh to avoid blocking the read
refresh_key(cache_key, ttl)
return cached
def get_with_lock(cache_key: str, ttl: int) -> str | None:
"""
Cache-aside with distributed lock to prevent stampede.
Only one request loads from DB and repopulates the cache.
"""
lock_key = f"{cache_key}:lock"
lock_id = str(uuid.uuid4())
# Try to acquire the lock (with a short TTL to prevent deadlocks)
acquired = r.set(lock_key, lock_id, nx=True, ex=5)
if acquired:
try:
# We got the lock — load from DB and populate cache
data = db_load_user(cache_key.split(":")[1])
if data:
r.setex(cache_key, ttl=ttl, value=json.dumps(data))
return json.dumps(data) if data else None
finally:
# Release the lock
if r.get(lock_key) == lock_id.encode():
r.delete(lock_key)
else:
# Another request has the lock — wait briefly and retry cache
time.sleep(0.1)
cached = r.get(cache_key)
if cached:
return cached
# Still a miss — recurse with lock (max 3 retries)
return get_with_lock(cache_key, ttl)
def refresh_key(cache_key: str, ttl: int) -> None:
"""Background refresh — don't block the read path."""
threading.Thread(
target=lambda: r.setex(cache_key, ttl=ttl, value=db_load_user(cache_key.split(":")[1])),
daemon=True
).start()
Other Stampede Prevention Techniques
1. Stale-While-Revalidate (SWR)
Serve the stale cached value immediately while refreshing it in the background. Redis doesn’t support SWR natively, but you can implement it using a shadow key:
def get_with_swr(cache_key: str, base_ttl: int, refresh_ttl: int) -> str | None:
"""
Stale-While-Revalidate: serve stale data immediately, refresh async.
"""
cached = r.get(cache_key)
if not cached:
return get_with_lock(cache_key, base_ttl)
remaining = r.ttl(cache_key)
if remaining < 0: # expired
# Spawn async refresh and return stale data if available
stale = r.get(f"{cache_key}:stale")
if stale:
threading.Thread(target=lambda: populate_cache(cache_key, base_ttl), daemon=True).start()
return stale
return get_with_lock(cache_key, base_ttl)
return cached
2. Token Bucket Refresh
Limit refresh attempts to a fixed rate using a token bucket stored in Redis, preventing more than N refreshes per second:
def try_refresh(cache_key: str, rate_limit_key: str, rate: int = 10) -> bool:
"""
Token bucket: allow at most `rate` refreshes per second globally.
"""
count = r.incr(rate_limit_key)
if count == 1:
r.expire(rate_limit_key, 1)
return count <= rate
3. Locking with probabilistic early expiration is the most battle-tested combination for high-traffic production systems. Studies on Redis-based caching infrastructure show that stampede prevention reduces peak database load by 40–70% during flash traffic events (Redis.io, February 2026).
Redis Cluster and Sharding
Single-instance Redis has a hard upper limit on throughput and memory. For systems requiring more than ~150K ops/sec or datasets larger than a single machine’s RAM, Redis Cluster provides horizontal scaling by automatically sharding data across multiple nodes.
How Redis Cluster Sharding Works
Redis Cluster divides the keyspace into 16,384 slots (not to be confused with database numbers). Each slot is assigned to a master node. When you write SET user:42, Redis computes CRC16(key) mod 16384 to determine which slot owns that key, then routes the command to the appropriate node.
Client (Cluster-aware)
|
v
Slot 1234 -----> Node A (master) [slots 0-5460]
Slot 5678 -----> Node B (master) [slots 5461-10922]
Slot 9012 -----> Node C (master) [slots 10923-16383]
Setting Up Redis Cluster (Python)
from redis.cluster import RedisCluster
nodes = [
{"host": "redis-node-1", "port": 6379},
{"host": "redis-node-2", "port": 6379},
{"host": "redis-node-3", "port": 6379},
]
rc = RedisCluster(startup_nodes=nodes, decode_responses=True)
# The cluster-aware client routes automatically
rc.set("user:42", json.dumps({"name": "Alice", "email": "[email protected]"}))
user = rc.get("user:42")
# Multi-key operations require all keys to be in the same slot (or use hash tags)
# Use curly braces {user} to force keys to the same slot:
rc.set("user:{user}:profile", data) # both keys with {user} go to same slot
rc.set("user:{user}:preferences", data)
Hash Tags for Multi-Key Operations
Redis Cluster can only safely execute multi-key transactions or Lua scripts when all keys belong to the same slot. Hash tags — the substring inside curly braces — force keys to share a slot:
# Without hash tag: user:42:profile and user:42:settings might be on different nodes
# With hash tag: user:{42}:profile and user:{42}:settings are always on the same node
Replication and Failover
Each master in a Redis Cluster can have one or more replica nodes. If a master fails, a replica is promoted automatically (within ~10–30 seconds depending on configuration). Reads can be served from replicas to spread load, though you’ll need to handle replica lag (stale reads from replicas that haven’t fully replicated from the master).
# Read from replica to spread load
rc = RedisCluster(startup_nodes=nodes, decode_responses=True, read_from_replicas=True)
When to Shard
Shard when you hit any of these thresholds:
| Metric | Threshold | Reason |
|---|---|---|
| Memory per node | > 100 GB | Larger datasets risk longer failover times |
| Ops/sec | > 150,000 | Single Redis instance saturates CPU core |
| Network bandwidth | > 10 Gbps | Network becomes the bottleneck |
| Geographic distribution | Multiple regions | Replicas enable low-latency reads per region |
For most mid-scale applications, a well-optimized single Redis instance handles millions of ops/sec due to its in-memory, single-threaded design. Redis Cluster is primarily for scale, not raw speed.
Common Pitfalls and How to Avoid Them
1. Caching Without a TTL
The single most common mistake. Unset TTLs cause cached data to grow unbounded until Redis hits maxmemory and starts evicting with whatever policy is configured (often LRU, which may evict exactly the keys you wanted to keep).
Fix: Always set a TTL. Calculate it based on data volatility:
# Always use setex, never plain set, for cache entries
r.setex(cache_key, ttl=300, value=data) # ✅
r.set(cache_key, data) # ❌ TTL-less key
2. Using Redis as a Primary Database
Redis has no built-in durability guarantees by default (AOF and RDB persistence mitigate this, but don’t eliminate it). Treating Redis as a source of truth without a database backing it is a recipe for data loss.
Fix: Always pair Redis with a durable primary database. Redis is a cache (or a session store, or a message broker) — not a replacement for PostgreSQL or MySQL.
3. Not Monitoring Cache Hit Rate
A cache with a 50% hit rate is only half as effective as it could be, and you won’t know unless you monitor it.
Fix: Use Redis INFO command to track hit/miss ratios:
import redis
def get_cache_stats(r: redis.Redis) -> dict:
info = r.info("stats")
keyspace_hits = info.get("keyspace_hits", 0)
keyspace_misses = info.get("keyspace_misses", 0)
total = keyspace_hits + keyspace_misses
hit_rate = (keyspace_hits / total * 100) if total > 0 else 0
return {
"hits": keyspace_hits,
"misses": keyspace_misses,
"hit_rate_pct": round(hit_rate, 2),
}
If hit rate drops below 80%, investigate why: cache too small (evictions happening), TTL too short, or access patterns have shifted.
4. Ignoring Memory Fragmentation
Redis’s memory allocator (jemalloc by default) can leave fragmentation ratio > 1.5 under certain workloads, meaning you’re using 50% more RAM than the logical dataset size. Azure Cache for Redis documentation explicitly calls out that Defender antivirus scans on C0 and C1 tier VMs can cause latency spikes — a reminder that Redis is sensitive to resource contention (Microsoft Learn).
Fix: Monitor mem_fragmentation_ratio via INFO memory and restart Redis periodically if it climbs above 1.5, or use MEMORY PURGE to defragment.
info = r.info("memory")
fragmentation = info.get("mem_fragmentation_ratio")
if fragmentation and fragmentation > 1.5:
r.execute_command("MEMORY PURGE")
5. Storing Large Values
Redis’s value size limit is 512 MB, but storing multi-MB values in Redis clogs the single-threaded command loop and causes latency spikes for all other clients.
Fix: Store large objects in object storage (S3, GCS) and cache only the reference URI in Redis. For data structures like search indexes, consider Redis modules like RediSearch or RedisJSON that are purpose-built for complex queries.
6. Not Handling Redis Failures Gracefully
A Redis outage should not take down your application. If your code doesn’t handle connection errors, a Redis restart cascades into a database overload.
Fix: Implement circuit breakers and fallbacks:
import redis
from redis.exceptions import RedisError
def get_user_robust(user_id: str) -> dict | None:
try:
cached = r.get(f"user:{user_id}")
if cached:
return json.loads(cached)
user = db_load_user(user_id)
if user:
try:
r.setex(f"user:{user_id}", ttl=300, value=json.dumps(user))
except RedisError:
pass # cache write failure is non-fatal
return user
except RedisError:
# Redis is down — fall through to database
return db_load_user(user_id)
7. Key Naming Collisions in Large Teams
As your Redis usage grows, key name collisions become a real risk if naming conventions aren’t enforced.
Fix: Use a consistent namespacing scheme:
{domain}:{entity}:{id}:{attribute}
{domain}:{entity}:{id}:{sub-entity}
Examples:
user:profile:42
user:session:a8f3-21bc
product:catalog:99:price
rate:limit:api:192.168.1.1
Putting It All Together: Choosing the Right Pattern
Here’s a quick decision framework:
| Pattern | Write Consistency | Read Speed | Write Speed | Best For |
|---|---|---|---|---|
| Cache-Aside | Eventual | ✅ Fast (post-miss) | N/A (read-only cache) | Read-heavy, tolerates staleness |
| Write-Through | Strong | ✅ Fast always | ⚠️ Slower (2 round trips) | Write-once-read-many, config data |
| Write-Behind | Eventual (async) | ✅ Fast | ✅ Fastest | High-write throughput, analytics |
| TTL Tiering | N/A (invalidation) | ✅ Fast | N/A | All patterns combined with TTL |
| Stampede Prevention | N/A (reliability) | ✅ Consistent | N/A | High-traffic cache entries |
In practice, most production systems combine multiple patterns. A common architecture: use cache-aside with TTL as the base read strategy, write-through for critical configuration writes, write-behind for analytics and event logging, probabilistic early expiration for the top 20% of keys by traffic, and Redis Cluster once throughput or memory demands exceed a single node.
Redis’s simplicity as a data structure server — strings, lists, sorted sets, hashes, bitmaps, hyperloglogs, streams — means it can serve as a cache, a session store, a rate limiter, a message queue, and a real-time analytics engine all within the same deployment. The pattern you choose shapes your application’s consistency model, performance characteristics, and operational complexity. Choose deliberately.
Sources referenced in this article:
- dasroot.net — KV Cache Latency Metrics (Redis/Valkey), February 2026
- Medium — Redis Caching Strategy: 95% Cache Hit Rate Achievement, June 2025
- Redis.io — Guide to Cache Optimization Strategies, February 2026
- Microsoft Learn — Azure Cache for Redis Best Practices
- Redis.io — Official Redis Benchmark Documentation
- DoHost.us — Real-World Benchmarks: Database Before and After Redis, March 2026
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.