Redis Cache Patterns for High-Performance Applications
How to implement cache-aside, write-through, and write-behind patterns with Redis to reduce database load and improve response times
Redis is an in-memory data structure store that works as an extremely fast cache layer between your application and persistent database. Choosing the right caching pattern — cache-aside, write-through, or write-behind — determines how your application handles cache misses, consistency, and failure scenarios.
When to Use This
- Database queries are slow and return frequently accessed data. See Query Optimization for tuning slow queries.
- You need to reduce load on primary databases during traffic spikes. See Rate Limiting for traffic control.
- Temporary data staleness is acceptable in exchange for lower latency
Prerequisites
- Redis server running locally or via a managed service
- A client library like
ioredisorredisfor Node.js
Solution
1. Cache-Aside (Lazy Loading)
The application checks the cache first. On a miss, it loads from the database and populates the cache.
// cache/CacheAside.ts
import Redis from 'ioredis';
class CacheAsideProductRepository {
private redis = new Redis();
private ttl = 300; // 5 minutes
async getProduct(id: string): Promise<Product | null> {
const cacheKey = `product:${id}`;
// Check cache first
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss: load from database
const product = await this.db.query('SELECT * FROM products WHERE id = $1', [id]);
if (!product) return null;
// Populate cache
await this.redis.setex(cacheKey, this.ttl, JSON.stringify(product));
return product;
}
async updateProduct(id: string, data: Partial<Product>): Promise<void> {
await this.db.query('UPDATE products SET ... WHERE id = $1', [id]);
// Invalidate cache to prevent stale reads
await this.redis.del(`product:${id}`);
}
}
2. Write-Through
Data is written to both cache and database simultaneously. The cache always holds the latest data.
// cache/WriteThrough.ts
class WriteThroughProductRepository {
async updateProduct(id: string, data: Partial<Product>): Promise<void> {
const cacheKey = `product:${id}`;
// Start database transaction. See [Database Transactions](/recipes/database-transactions/) for ACID patterns.
await this.db.query('BEGIN');
try {
await this.db.query('UPDATE products SET ... WHERE id = $1', [id]);
// Write to cache within the same logical operation
const updated = await this.db.query('SELECT * FROM products WHERE id = $1', [id]);
await this.redis.setex(cacheKey, this.ttl, JSON.stringify(updated));
await this.db.query('COMMIT');
} catch (error) {
await this.db.query('ROLLBACK');
throw error;
}
}
}
3. Write-Behind (Write-Back)
Data is written to cache first and asynchronously flushed to the database. Highest performance but riskiest.
// cache/WriteBehind.ts
class WriteBehindProductRepository {
async updateProduct(id: string, data: Partial<Product>): Promise<void> {
const cacheKey = `product:${id}`;
// Write to cache immediately
await this.redis.setex(cacheKey, this.ttl, JSON.stringify(data));
// Queue for async persistence
await this.redis.lpush('pending_writes', JSON.stringify({ id, data, timestamp: Date.now() }));
}
}
// Background worker. See [Batch Processing](/recipes/batch-processing-patterns/) for job patterns.
async function flushPendingWrites() {
const batch = await redis.lpop('pending_writes', 100);
if (!batch) return;
const writes = batch.map(item => JSON.parse(item));
await db.query('BEGIN');
try {
for (const write of writes) {
await db.query('UPDATE products SET ... WHERE id = $1', [write.id]);
}
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
// Re-queue failed writes
for (const write of writes) {
await redis.rpush('pending_writes', JSON.stringify(write));
}
}
}
// Run every 5 seconds
setInterval(flushPendingWrites, 5000);
4. Cache Stampede Prevention
// cache/StampedeProtection.ts
class StampedeProtectedCache {
async getProduct(id: string): Promise<Product> {
const cacheKey = `product:${id}`;
const lockKey = `lock:${id}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Try to acquire lock
const lock = await this.redis.set(lockKey, '1', 'EX', 10, 'NX');
if (lock) {
// We won the race; load from DB
const product = await this.db.query('SELECT * FROM products WHERE id = $1', [id]);
await this.redis.setex(cacheKey, this.ttl, JSON.stringify(product));
await this.redis.del(lockKey);
return product;
}
// Wait for the winner to populate cache
await new Promise(resolve => setTimeout(resolve, 100));
return this.getProduct(id);
}
}
How It Works
- Cache-Aside minimizes cache writes but allows brief stale data after updates
- Write-Through guarantees consistency at the cost of higher write latency
- Write-Behind maximizes throughput but risks data loss if the cache fails before flush
- Stampede Protection prevents multiple simultaneous database queries on cache expiration
Production Considerations
- Use Redis Cluster or Redis Sentinel for high availability
- Implement circuit breaker logic when Redis is unavailable; fall back to database
- Set appropriate TTL values based on data change frequency
- Monitor cache hit ratio with
INFO statsand adjust TTL accordingly
Common Mistakes
- Not handling Redis connection failures gracefully
- Using the same TTL for all data types regardless of change frequency
- Forgetting to invalidate related cache entries on updates
Performance Tips
- Use
MSETandMGETfor batch operations. Reduce round-trips when setting or getting multiple keys:
# Bad: 100 round-trips
for i in range(100):
r.set(f"key:{i}", f"value:{i}")
# Good: 1 round-trip
r.mset({f"key:{i}": f"value:{i}" for i in range(100)})
- Use
HSETwith multiple fields. Hash operations are more memory-efficient for structured data:
r.hset("user:42", mapping={
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
})
- Use
SETEXinstead ofSET+EXPIRE.SETEXis atomic and saves a round-trip:
# Bad: two commands
r.set("key", "value")
r.expire("key", 300)
# Good: one atomic command
r.setex("key", 300, "value")
- Enable
tcp-keepalivein Redis. Prevent stale connections from consuming resources:
# redis.conf
tcp-keepalive 60
- Use
CLIENT INFOto debug connection leaks. Track how many connections each client holds:
redis-cli CLIENT LIST
# Show idle times and connection ages Frequently Asked Questions
Which pattern should I use?
Cache-aside for read-heavy workloads. Write-through when consistency is critical. Write-behind only when you can tolerate brief data loss.
How do I handle cache invalidation across multiple services?
Use Redis Pub/Sub or a message queue to broadcast invalidation events to all service instances.
Should I compress cached data?
For large objects (>1KB), yes. Use msgpack or JSON compression to reduce memory usage and network transfer.
Related Resources
Implement Cache Invalidation Strategies
How to keep caches consistent with databases using TTL, write-through, write-behind, and event-driven invalidation patterns.
RecipeSet Up Connection Pooling for Databases and HTTP Clients
Set up connection pooling for PostgreSQL, MySQL, Redis, and HTTP clients in Python, JavaScript, and Java. Reduce latency and avoid connection exhaustion.
GuideWeb Performance Optimization Guide
A thorough guide to optimizing web application performance for better Core Web Vitals and user experience.
RecipeCache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
RecipeConnect to Redis
How to connect to Redis and perform basic operations in Python, JavaScript, and Java.
RecipePostgreSQL Query Optimization and Indexing Strategies
Analyze and optimize slow PostgreSQL queries using EXPLAIN, proper indexing, partial indexes, and query rewriting to reduce execution time from seconds to milliseconds