Complete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
Redis is the most popular in-memory data store for caching. It is fast, versatile, and supports multiple data structures. But using Redis effectively requires choosing the right caching strategy for each use case. The wrong strategy leads to stale data, cache stampedes, or wasted memory. Below is a practical guide to the five main Redis caching patterns, eviction policies, TTL tuning, serialization choices, and production operations.
Caching Patterns Overview
Pattern Read Flow Write Flow
─────────────────────────────────────────────────────────────────
Cache-Aside App → Redis → DB → Redis App → DB, then invalidate Redis
Read-Through App → Cache Layer → DB App → Cache Layer → DB
Write-Through App → Cache → DB (sync) App → Cache, Cache → DB
Write-Behind App → Cache (async → DB) App → Cache, Cache async → DB
Refresh-Ahead Background refresh before TTL App → DB, then update Redis
Each pattern has different tradeoffs in consistency, latency, and complexity.
Cache-Aside (Lazy Loading)
The application manages the cache explicitly. On a read, check Redis first. If the data is not there (cache miss), read from the database, write to Redis, and return. On a write, update the database and delete the cache entry.
Read Flow
import redis
import json
r = redis.Redis(host="localhost", port=6379, db=0)
def get_user(user_id: int) -> dict | None:
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss: read from database
user = db.users.find_by_id(user_id)
if user:
r.setex(cache_key, 3600, json.dumps(user))
return user
Write Flow
def update_user(user_id: int, data: dict) -> dict:
user = db.users.update(user_id, data)
# Invalidate cache entry
r.delete(f"user:{user_id}")
return user
Advantages
- Simple to implement
- Cache only contains data that is actually requested
- Resilient to cache failures (falls back to database)
Disadvantages
- Cache miss adds latency (database read + cache write)
- Data can be stale between writes and next read
- Cache stampede on cold cache or after mass invalidation
Preventing Cache Stampede
When many requests miss the cache simultaneously, they all hit the database. Use a lock to let only one request fetch from the database.
import time
def get_user_safe(user_id: int) -> dict | None:
cache_key = f"user:{user_id}"
lock_key = f"lock:user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Try to acquire lock
acquired = r.set(lock_key, "1", nx=True, ex=10)
if acquired:
try:
# Double-check cache after acquiring lock
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db.users.find_by_id(user_id)
if user:
r.setex(cache_key, 3600, json.dumps(user))
return user
finally:
r.delete(lock_key)
else:
# Wait and retry
time.sleep(0.1)
return get_user_safe(user_id)
Read-Through
The application talks to a cache layer that transparently fetches from the database on a miss. The application does not know about the database.
class ReadThroughCache:
def __init__(self, redis_client, db_loader):
self.redis = redis_client
self.db_loader = db_loader
def get(self, key: str, ttl: int = 3600) -> dict | None:
cached = self.redis.get(key)
if cached:
return json.loads(cached)
# Load from database
data = self.db_loader(key)
if data:
self.redis.setex(key, ttl, json.dumps(data))
return data
user_cache = ReadThroughCache(r, lambda key: db.users.find_by_id(int(key.split(":")[1])))
user = user_cache.get("user:42")
Advantages
- Application code is cleaner (no cache logic)
- Consistent caching behavior across all read paths
- Easy to add caching to existing code
Disadvantages
- Cache layer is a single point of failure
- Less control over when to cache and when not to
- Stale data until TTL expires
Write-Through
Writes go to the cache first, then synchronously to the database. The cache always has the latest data.
class WriteThroughCache:
def __init__(self, redis_client, db_writer):
self.redis = redis_client
self.db_writer = db_writer
def set(self, key: str, value: dict, ttl: int = 3600) -> dict:
# Write to database first
result = self.db_writer(key, value)
# Then write to cache
self.redis.setex(key, ttl, json.dumps(result))
return result
user_cache = WriteThroughCache(r, lambda key, val: db.users.update(int(key.split(":")[1]), val))
user = user_cache.set("user:42", {"name": "Alice"})
Advantages
- Cache is always consistent with database
- No stale data
- Simple to reason about
Disadvantages
- Write latency is higher (two synchronous writes)
- If cache fails, write fails (or you need a fallback)
- Not suitable for write-heavy workloads
Write-Behind (Write-Back)
Writes go to the cache first. A background process asynchronously writes to the database. This reduces write latency but introduces a window of potential data loss.
import threading
import queue
write_queue = queue.Queue()
class WriteBehindCache:
def __init__(self, redis_client, db_writer):
self.redis = redis_client
self.db_writer = db_writer
def set(self, key: str, value: dict, ttl: int = 3600) -> dict:
# Write to cache immediately
self.redis.setex(key, ttl, json.dumps(value))
# Queue for async database write
write_queue.put((key, value))
return value
# Background worker
def write_worker():
while True:
key, value = write_queue.get()
try:
db_writer(key, value)
except Exception as e:
print(f"Write failed for {key}: {e}")
# Retry logic here
finally:
write_queue.task_done()
threading.Thread(target=write_worker, daemon=True).start()
Advantages
- Very low write latency (only Redis write is synchronous)
- High throughput for write-heavy workloads
- Database load is smoothed (batch writes possible)
Disadvantages
- Data loss risk if Redis crashes before database write
- Complex to implement correctly (ordering, retries, idempotency)
- Hard to debug consistency issues
When to Use Write-Behind
-
For alternatives, see Cache Database Query Results with Redis and Python.
-
High-volume writes where slight data loss is acceptable (analytics, counters)
-
Write-heavy workloads where database is the bottleneck
-
Scenarios where eventual consistency is acceptable
Refresh-Ahead
A background process refreshes cache entries before they expire. This prevents cache misses and keeps data fresh.
import threading
import time
def refresh_ahead():
while True:
# Find keys expiring soon (within next 5 minutes)
keys = r.scan(match="user:*", count=100)
for key in keys:
ttl = r.ttl(key)
if 0 < ttl < 300: # Expires in less than 5 minutes
user_id = int(key.split(":")[1])
user = db.users.find_by_id(user_id)
if user:
r.setex(key, 3600, json.dumps(user))
time.sleep(60) # Check every minute
threading.Thread(target=refresh_ahead, daemon=True).start()
Advantages
- No cache misses for hot keys
- Data stays fresh
- Smooth database load (proactive, not reactive)
Disadvantages
- Wastes resources refreshing cold keys that are no longer accessed
- Complex to implement (need to track which keys are hot)
- Stale data if refresh fails
Eviction Policies
When Redis reaches its memory limit, it evicts keys based on the configured policy.
| Policy | Description | Best For |
|---|---|---|
noeviction | Returns error on writes when memory is full | Critical data that must not be lost |
allkeys-lru | Evicts least recently used key (any key) | General-purpose caching |
allkeys-lfu | Evicts least frequently used key (any key) | Skewed access patterns |
volatile-lru | Evicts LRU key among keys with TTL set | Mix of cached and persistent data |
volatile-lfu | Evicts LFU key among keys with TTL set | Mix of cached and persistent data |
volatile-ttl | Evicts key with shortest TTL | Time-sensitive data |
volatile-random | Evicts random key among keys with TTL set | Simple, low overhead |
allkeys-random | Evicts random key (any key) | Uniform access patterns |
Choosing an Eviction Policy
# Set eviction policy in redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
For most caching workloads, allkeys-lru is the right choice. It evicts the least recently used keys, which are likely to be the least useful. Use volatile-lru when you store both cached data (with TTL) and persistent data (without TTL) in the same Redis instance.
TTL Tuning
Setting TTLs
Always set a TTL on cached data. Without a TTL, data stays in Redis forever, consuming memory.
# Short TTL for frequently changing data
r.setex("session:abc123", 900, session_data) # 15 minutes
# Medium TTL for moderately changing data
r.setex("user:42", 3600, user_data) # 1 hour
# Long TTL for rarely changing data
r.setex("config:app", 86400, config_data) # 24 hours
TTL Jitter
When many keys expire at the same time, a cache stampede occurs. Add random jitter to TTLs to spread expirations.
import random
def set_with_jitter(key: str, value: str, base_ttl: int = 3600, jitter: int = 300):
actual_ttl = base_ttl + random.randint(0, jitter)
r.setex(key, actual_ttl, value)
TTL vs Eviction
TTL controls when data expires. Eviction controls what gets removed when memory is full. Both are needed: TTL prevents stale data, eviction prevents out-of-memory.
Serialization
JSON
JSON is human-readable and supported everywhere. Use it for most cached data.
import json
r.set("user:42", json.dumps({"id": 42, "name": "Alice"}))
user = json.loads(r.get("user:42"))
MessagePack
MessagePack is more compact than JSON. Use it for large objects where memory matters.
import msgpack
r.set("user:42", msgpack.packb({"id": 42, "name": "Alice"}))
user = msgpack.unpackb(r.get("user:42"), raw=False)
Redis Hashes
For objects with many fields, use Redis hashes instead of serialized strings. This allows partial updates.
r.hset("user:42", mapping={"name": "Alice", "email": "alice@example.com", "age": "30"})
name = r.hget("user:42", "name")
Production Operations
Monitoring
Track these metrics in production:
- Hit rate:
hits / (hits + misses)— should be above 80% for most workloads - Memory usage:
used_memory / maxmemory— should stay below 80% - Eviction rate: keys evicted per second — high rate means you need more memory
- Latency: p99 for GET and SET — should be under 1ms
- Connected clients: monitor for connection leaks
- Persistence status: if using RDB/AOF, check last save time
# Check Redis stats
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"
# Check memory
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human"
Persistence
Redis offers two persistence options:
- RDB (snapshotting): Point-in-time snapshots. Fast recovery, potential data loss between snapshots.
- AOF (append-only file): Logs every write operation. Slower, minimal data loss.
For caching workloads where data can be rebuilt from the database, RDB is sufficient. For write-behind patterns where Redis is the source of truth temporarily, use AOF.
# redis.conf
save 900 1 # Save if at least 1 key changed in 15 minutes
save 300 10 # Save if at least 10 keys changed in 5 minutes
appendonly yes
appendfsync everysec
Clustering
When a single Redis instance is not enough, use Redis Cluster for horizontal scaling.
from redis.cluster import RedisCluster
rc = RedisCluster(host="localhost", port=7000)
# Redis Cluster automatically shards keys across nodes
rc.set("user:42", json.dumps({"name": "Alice"}))
Connection Pooling
Always use connection pooling in production. Creating a new connection per request is expensive.
pool = redis.ConnectionPool(host="localhost", port=6379, max_connections=50)
r = redis.Redis(connection_pool=pool)
Common Pitfalls
Storing Large Objects
Large objects (>1MB) in Redis consume memory and slow down operations. Store references instead of full objects, or use a dedicated blob store.
# Bad: storing a 5MB image in Redis
r.set("image:42", large_image_bytes)
# Good: store metadata in Redis, image in S3
r.hset("image:42", mapping={"url": "s3://bucket/image42.jpg", "width": "1920", "height": "1080"})
Using KEYS in Production
The KEYS command blocks Redis while scanning all keys. Use SCAN instead.
# Bad: blocks Redis
keys = r.keys("user:*")
# Good: non-blocking, returns in batches
for key in r.scan_iter(match="user:*", count=100):
process(key)
Not Handling Cache Failures
If Redis goes down, your application should fall back to the database, not crash.
def get_user_resilient(user_id: int) -> dict | None:
try:
cached = r.get(f"user:{user_id}")
if cached:
return json.loads(cached)
except redis.ConnectionError:
pass # Fall back to database
user = db.users.find_by_id(user_id)
if user:
try:
r.setex(f"user:{user_id}", 3600, json.dumps(user))
except redis.ConnectionError:
pass # Cache is down, continue without caching
return user
FAQ
Which caching pattern should I start with?
Start with cache-aside. It is the simplest to implement and reason about. Add more complex patterns (write-through, write-behind) only when you have specific latency or throughput requirements that cache-aside cannot meet.
How much memory should I allocate to Redis?
Allocate enough memory to hold your working set (frequently accessed data) with 20-30% headroom. Monitor eviction rate: if it is high, increase memory or reduce TTLs.
Should I use Redis for session storage?
Yes. Redis is well-suited for session storage. Set a TTL equal to the session timeout. Use allkeys-lru or volatile-lru eviction policy so old sessions are evicted when memory is full.
How do I test cache behavior?
Write integration tests that verify: cache hits return cached data, cache misses fetch from database and populate cache, writes invalidate cache, TTL expiration triggers database fetch. Use a test Redis instance (not production).
What is the difference between Redis and Memcached?
Redis supports multiple data structures (strings, hashes, lists, sets, sorted sets), persistence, pub/sub, and clustering. Memcached is simpler (key-value only) and multithreaded. Use Redis for most caching workloads. Use Memcached for simple, high-throughput key-value caching where you do not need persistence or advanced data structures.
Should I use Redis Cluster or Redis Sentinel?
Redis Cluster shards data across multiple nodes for horizontal scaling. Redis Sentinel provides high availability (automatic failover) without sharding. Use Cluster when you need more capacity than a single node can provide. Use Sentinel when you need high availability but a single node has enough capacity.
Related Resources
Complete Guide to GraphQL Caching
Cache GraphQL responses at every layer: CDN, gateway, DataLoader, persisted queries, and client-side. Covers cache keys, invalidation, HTTP caching directives, and Apollo Client cache.
PatternCache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.
PatternWrite-Through Cache Pattern
Synchronously write to both cache and backing store so the cache always has the latest data without TTL-based invalidation.
GuideComplete Guide to Cache Invalidation
Master cache invalidation strategies: TTL expiration, event-driven invalidation, versioned keys, tag-based purging, and write-through invalidation. Covers multi-tier invalidation, race conditions, and consistency patterns.
RecipeMulti-Level Cache with In-Memory L1 and Redis L2
Implement a two-level cache combining in-memory L1 and Redis L2 for low-latency reads with cross-instance consistency
RecipeDistributed Locking with Redis and Redlock
Implement distributed locks with Redis for mutual exclusion across processes, using SET NX with TTL and the Redlock algorithm for reliability
RecipeCaching Strategies
Implement useful caching strategies for databases, APIs, and frontends using Redis, CDNs, and browser caches.