Redis Caching Explained: How It Works, Patterns, and Code Examples
Redis is an in-memory data store used as a cache to reduce database load and latency. Learn cache-aside, TTL, stampede prevention, eviction policies, and complete Python/Node.js code examples.
Redis is an open-source, in-memory data structure store used as a cache, database, and message broker. When used as a cache, Redis sits between your application and your primary database. Instead of querying the database on every request, your app checks Redis first. If the data is there (a cache hit), Redis returns it in under 1 millisecond. If not (a cache miss), the app fetches from the database, stores the result in Redis, and returns it. Subsequent requests get the cached copy until it expires.
Your app makes the same database query hundreds of times per second. Each time, it goes all the way to the database, waits for a result, and sends it back. That's slow and expensive. Redis caching fixes this at the architectural level — not by optimizing queries, but by eliminating most of them entirely.
Why Redis? What Makes It Fast
Redis stores data in memory (RAM), not on disk. RAM access is roughly 100,000× faster than disk. That's why Redis can handle over 1 million operations per second on a single server with sub-millisecond latency.
Redis is also single-threaded for command execution, which means no lock contention. It processes commands sequentially with an event loop, and because RAM operations are so fast, this design outperforms multi-threaded databases for most workloads.
Redis stores key-value pairs with optional expiry:
"user:123"→{"name": "Alice", "email": "alice@example.com"}"product:456:price"→29.99"session:abc123"→{"user_id": 42, "expires": 1700000000}
That simplicity makes it extremely fast and easy to reason about.
Redis Data Structures for Caching
Redis is not just a simple key-value store. It supports multiple data structures, each with cache-specific use cases.
String
The most basic type. Stores any binary-safe value up to 512 MB. Used for single values, serialized JSON, and counters.
# Cache serialized JSON
r.setex("user:123", 3600, json.dumps(user_data))
# Counter (rate limiting, view counts)
r.incr("page:views:/blog/redis")
r.expire("page:views:/blog/redis", 60) # reset every minuteHash
Stores a map of field-value pairs under one key. Better than serializing JSON when you need to update individual fields without fetching the whole object.
# Store user fields individually
r.hset("user:123", mapping={
"name": "Alice",
"email": "alice@example.com",
"plan": "pro"
})
r.expire("user:123", 3600)
# Update just one field — no JSON parse/serialize needed
r.hset("user:123", "plan", "enterprise")
# Get one field
plan = r.hget("user:123", "plan")
# Get all fields
user = r.hgetall("user:123")Cache use case: User profiles, configuration objects, feature flags per user.
List
An ordered collection of strings, stored as a doubly-linked list. Supports push/pop from both ends in O(1).
# Cache recent activity feed (keep last 100 items)
r.lpush("feed:user:123", json.dumps(activity))
r.ltrim("feed:user:123", 0, 99) # keep only 100 most recent
r.expire("feed:user:123", 3600)
# Fetch the feed
feed = r.lrange("feed:user:123", 0, 49) # get latest 50Cache use case: Activity feeds, recent items, job queues.
Set
An unordered collection of unique strings. O(1) add, remove, membership check.
# Cache which users have seen a notification
r.sadd("notification:456:seen", user_id)
r.expire("notification:456:seen", 86400)
# Check membership
has_seen = r.sismember("notification:456:seen", user_id)
# Find users online in multiple rooms (intersection)
online_in_both = r.sinter("room:1:online", "room:2:online")Cache use case: Unique visitor tracking, permission sets, tag-based filtering.
Sorted Set
Like a Set but every member has a score. Members are sorted by score. Used for leaderboards, time-series data, and priority queues.
# Leaderboard
r.zadd("leaderboard:weekly", {"alice": 1520, "bob": 980, "carol": 2100})
r.expire("leaderboard:weekly", 604800) # 1 week
# Get top 10
top_10 = r.zrevrange("leaderboard:weekly", 0, 9, withscores=True)
# Cache time-series events (score = timestamp)
import time
r.zadd("events:user:123", {json.dumps(event): time.time()})
# Fetch events from last hour
now = time.time()
r.zrangebyscore("events:user:123", now - 3600, now)Cache use case: Leaderboards, rate limiting windows, time-ordered event caches.
Cache Invalidation Strategies
Cache invalidation is the process of removing or updating cached data when the source of truth changes. Phil Karlton's famous quote applies here: "There are only two hard things in computer science: cache invalidation and naming things."
TTL-Based Expiry (Passive Invalidation)
Set an expiry time and let Redis handle deletion automatically. The simplest approach — no code needed on updates.
# Data expires automatically after TTL
r.setex("product:789", 3600, json.dumps(product)) # expires in 1 hourWhen to use: Data that can tolerate some staleness (product descriptions, blog posts, recommendation lists).
Event-Driven Invalidation (Active Invalidation)
Delete or update the cache key when the underlying data changes. Guarantees freshness at the cost of more code.
def update_product(product_id: int, data: dict):
# 1. Write to database
db.execute("UPDATE products SET ... WHERE id = ?", product_id)
# 2. Invalidate all related cache keys
r.delete(f"product:{product_id}")
r.delete(f"product:{product_id}:reviews")
r.delete("products:featured") # if this product might appear in featuredWhen to use: Financial data, inventory counts, user permissions — anything where stale reads cause problems.
Cache-Aside vs Write-Through vs Write-Behind
| Strategy | Who Fetches? | Write Behavior | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside | Application | App writes DB, deletes cache | Eventual | Low |
| Write-Through | Cache layer | Write to cache + DB together | Strong | Medium |
| Write-Behind | Cache layer | Write to cache, DB update async | Eventual | High |
| Read-Through | Cache layer | Cache fetches from DB on miss | Eventual | Medium |
Cache-Aside (lazy loading) is the most common in practice. You control exactly what goes into the cache and when. The downside: first request after a miss is always slow.
Write-Through keeps cache and database in sync on every write. Reads are always fast. Downside: you cache data that might never be read.
Write-Behind (write-back) accepts writes into cache first and flushes to the database asynchronously. Very fast writes. Risky: if Redis crashes before flushing, you lose data.
Redis Cache Patterns with Code
Cache-Aside Pattern — Complete Implementation
Python with connection pooling:
import redis
import json
import logging
from typing import Optional
# Connection pooling — reuse connections across requests
pool = redis.ConnectionPool(
host="localhost",
port=6379,
db=0,
max_connections=20,
decode_responses=True,
)
r = redis.Redis(connection_pool=pool)
logger = logging.getLogger(__name__)
def get_user(user_id: int) -> Optional[dict]:
cache_key = f"user:{user_id}"
# 1. Try cache
try:
cached = r.get(cache_key)
if cached:
logger.debug(f"Cache hit: {cache_key}")
return json.loads(cached)
except redis.RedisError as e:
# Cache failure — degrade gracefully, don't crash
logger.error(f"Redis read error: {e}")
# 2. Cache miss — fetch from DB
logger.debug(f"Cache miss: {cache_key}")
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
if user is None:
return None
# 3. Populate cache
try:
r.setex(cache_key, 3600, json.dumps(user))
except redis.RedisError as e:
logger.error(f"Redis write error: {e}")
# Continue — DB result still returned
return user
def update_user(user_id: int, data: dict) -> None:
db.execute("UPDATE users SET ... WHERE id = %s", (user_id,))
# Invalidate — next read will repopulate
try:
r.delete(f"user:{user_id}")
except redis.RedisError as e:
logger.error(f"Redis delete error: {e}")Node.js with ioredis and connection pooling:
const Redis = require("ioredis");
const redis = new Redis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: 3,
lazyConnect: true,
// ioredis manages a single persistent connection by default
// For clusters, use Redis.Cluster
});
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// 1. Try cache
try {
const cached = await redis.get(cacheKey);
if (cached) {
console.log(`Cache hit: ${cacheKey}`);
return JSON.parse(cached);
}
} catch (err) {
console.error(`Redis read error: ${err.message}`);
// Degrade gracefully — fall through to DB
}
// 2. Cache miss — fetch from DB
console.log(`Cache miss: ${cacheKey}`);
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
if (!user) return null;
// 3. Populate cache
try {
await redis.setex(cacheKey, 3600, JSON.stringify(user));
} catch (err) {
console.error(`Redis write error: ${err.message}`);
// Continue — still return DB result
}
return user;
}
async function updateUser(userId, data) {
await db.query("UPDATE users SET ... WHERE id = $1", [userId]);
try {
await redis.del(`user:${userId}`);
} catch (err) {
console.error(`Redis delete error: ${err.message}`);
}
}Both examples follow the same pattern: try cache → degrade gracefully on error → fall back to DB → populate cache on miss.
Choosing the Right TTL
TTL is how long your cached data stays valid. Pick it based on how often the underlying data changes.
| Data type | Suggested TTL |
|---|---|
| Static content (product catalog) | 24 hours or more |
| User profiles | 1–4 hours |
| Session data | 30 minutes |
| Trending content (top 10 posts) | 1–5 minutes |
| Real-time data | No cache |
Setting TTL too long: users see stale data. Setting it too short: your cache doesn't help much. Most applications use a mix — long TTLs for rarely-changing data, short TTLs for frequently-updated data, and active invalidation for anything that must be immediately consistent.
Cache Stampede Problem and Solutions
A cache stampede (also called thundering herd) happens when a popular cache key expires and thousands of concurrent requests all miss the cache simultaneously, flooding your database with identical queries.
Imagine a product page with 10,000 concurrent users. The cache key "product:featured" expires. Every one of those 10,000 requests hits the database at the same instant. The database gets overwhelmed, latency spikes, and in the worst case the service falls over — right at peak traffic.
Solution 1: Mutex / Lock
Use a distributed lock so only one request rebuilds the cache while others wait.
import redis
import time
LOCK_TIMEOUT = 5 # seconds
def get_featured_products():
cache_key = "products:featured"
lock_key = f"{cache_key}:lock"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Try to acquire lock
acquired = r.set(lock_key, "1", nx=True, ex=LOCK_TIMEOUT)
if acquired:
try:
# This process rebuilds the cache
data = db.query("SELECT * FROM products WHERE featured = true")
r.setex(cache_key, 300, json.dumps(data))
return data
finally:
r.delete(lock_key)
else:
# Another process holds the lock — wait and retry
time.sleep(0.1)
return get_featured_products() # retrySolution 2: Probabilistic Early Expiration
Proactively refresh the cache slightly before it expires, using a probability that increases as the key approaches its TTL deadline. Avoids stampedes without locks.
import math
import random
import time
def get_with_early_refresh(key: str, ttl: int, fetch_fn):
cached = r.get(key)
remaining_ttl = r.ttl(key)
if cached:
# XFetch algorithm: refresh early with increasing probability
# beta controls aggression (higher = refresh earlier)
beta = 1.0
delta = 0.05 # estimated time to recompute (seconds)
should_refresh = (-delta * beta * math.log(random.random())) >= remaining_ttl
if not should_refresh:
return json.loads(cached)
# Cache miss or proactive refresh
data = fetch_fn()
r.setex(key, ttl, json.dumps(data))
return dataSolution 3: Jitter on TTL
The simplest approach: add random jitter to TTL so keys don't all expire at the same instant.
import random
# Without jitter: all keys for the 9pm batch expire at exactly 10pm
# With jitter: keys expire spread over 10pm–10:05pm
base_ttl = 3600
jitter = random.randint(0, 300) # up to 5 minutes of jitter
r.setex(cache_key, base_ttl + jitter, json.dumps(data))Use jitter when you cache many keys with the same TTL (e.g., a nightly batch that caches 50,000 product records). Without jitter, they all expire together.
Redis Memory Management
Redis is an in-memory store — memory is the primary constraint. You need to understand how Redis manages it.
maxmemory-policy
When Redis reaches its memory limit, it uses an eviction policy to free space. Set this in redis.conf or via CONFIG SET maxmemory-policy <policy>.
| Policy | What it evicts | When to use |
|---|---|---|
noeviction | Nothing — returns error | When you can't afford data loss |
allkeys-lru | Least recently used key (any key) | General caching — most common |
volatile-lru | LRU key that has a TTL set | Mixed cache + persistent data |
allkeys-lfu | Least frequently used key (any key) | Skewed access patterns |
volatile-lfu | LFU key with TTL | Mixed use, frequency-aware |
allkeys-random | Random key | Uniform access patterns |
volatile-ttl | Key with shortest remaining TTL | Expiry-aware eviction |
For pure caching workloads: Use allkeys-lru or allkeys-lfu.
- LRU (Least Recently Used): evicts keys not accessed recently
- LFU (Least Frequently Used): evicts keys accessed least often — better for skewed access patterns where some keys are always more popular
# Set in redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
# Or dynamically
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lruMemory Monitoring
# Current memory usage
redis-cli INFO memory | grep used_memory_human
# Memory per key type (sample 100 keys)
redis-cli --bigkeys
# Memory used by a specific key
redis-cli DEBUG OBJECT user:123
# Real-time stats
redis-cli MONITOR # WARNING: high overhead, use in dev onlyAlways set maxmemory in production. Without it, Redis will grow until the server runs out of RAM and the process is killed by the OS.
Redis vs Memcached
Both Redis and Memcached are in-memory caches. The choice matters for production architectures.
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | String, Hash, List, Set, Sorted Set, Stream, Bitmap | String only |
| Persistence | RDB snapshots + AOF logging | None |
| Replication | Built-in primary/replica | Not built-in |
| Clustering | Redis Cluster (native) | Consistent hashing (client-side) |
| Pub/Sub | Yes | No |
| Lua scripting | Yes | No |
| Transactions | Yes (MULTI/EXEC) | No |
| Max value size | 512 MB | 1 MB |
| Memory efficiency | Slightly higher overhead | Slightly more efficient for plain strings |
| Multi-threading | Single-threaded (commands) | Multi-threaded |
Use Redis when: You need persistence, complex data structures (leaderboards, feeds), pub/sub, Lua scripting, or replication. Redis is the right default for nearly all new projects.
Use Memcached when: You have a pure caching workload with simple string values, need maximum memory efficiency for very high request rates, and already have Memcached expertise on the team. The performance difference at equal hardware is marginal for most applications.
When NOT to Use Cache
Caching isn't always the answer. Avoid it when:
- Data changes extremely frequently (real-time prices, sensor readings)
- Data must always be perfectly accurate (bank balances during transactions)
- You have very few users (premature optimization)
- The database is already fast enough
- You can't afford the operational complexity of another service
Key Takeaways
- Redis stores data in RAM, making it ~100,000× faster than disk-based databases
- Cache-aside is the most common pattern: check cache → fall back to DB on miss → store in cache
- Always set a TTL — and add jitter to prevent stampedes when you have many keys with the same TTL
- Use connection pooling in production — creating a new connection per request kills performance
- Handle Redis errors gracefully — the cache should degrade, not crash your app
- Set
maxmemoryandmaxmemory-policyin production —allkeys-lruis the right default for caching - Use Hashes instead of serialized JSON when you update individual fields frequently
- Redis wins over Memcached in almost every dimension except pure string caching memory efficiency
Adding Redis caching to a slow endpoint is often the fastest performance win in backend engineering.
FAQ
What is Redis and what is it used for?
Redis (Remote Dictionary Server) is an open-source, in-memory data structure store. It is most commonly used as a cache to reduce database load and latency, but also as a session store, message broker (pub/sub), distributed lock manager, and leaderboard engine. Its primary advantage is speed — data lives in RAM, so reads and writes complete in under 1 millisecond.
What is the cache-aside pattern?
Cache-aside (also called lazy loading) is the most common caching pattern. The application checks the cache first. On a cache hit, it returns the cached value. On a cache miss, it fetches from the database, stores the result in the cache with a TTL, and returns it. The application controls all cache interactions — there is no automatic synchronization between cache and database.
How do I set an expiry time in Redis?
Use SETEX key seconds value to set a key with an expiry in one command. Or use SET key value EX seconds. You can also set expiry on an existing key with EXPIRE key seconds. In Python: r.setex("user:123", 3600, value) sets the key to expire in 3600 seconds (1 hour). Use r.ttl("user:123") to check remaining TTL.
What is a cache stampede and how do I prevent it?
A cache stampede happens when a popular cache key expires and many concurrent requests all miss simultaneously, flooding the database. Prevent it with: (1) a distributed mutex lock so only one request rebuilds the cache while others wait; (2) probabilistic early expiration (XFetch algorithm) to proactively refresh the cache before it expires; or (3) TTL jitter — adding random seconds to TTL so keys don't expire at the same instant.
What is the difference between Redis and Memcached?
Redis supports multiple data structures (strings, hashes, lists, sets, sorted sets), persistence to disk, built-in replication, pub/sub, and Lua scripting. Memcached stores strings only, has no persistence, and is simpler. Redis is the right default for new projects. Memcached has a slight memory efficiency advantage for pure string caching at very high throughput, but the difference rarely matters at typical scale.
How does Redis handle cache eviction?
When Redis reaches its maxmemory limit, it uses the configured maxmemory-policy to decide which keys to evict. allkeys-lru evicts the least recently used key across all keys and is the recommended policy for pure caching workloads. allkeys-lfu (least frequently used) is better when some keys are always more popular. Without a maxmemory setting, Redis will grow until the OS kills the process — always set this in production.
How do I implement distributed caching with Redis?
For distributed caching across multiple application servers, all app instances connect to the same Redis instance (or Redis Cluster for horizontal scaling). Use consistent cache key naming (resource:id:field) so all app servers share the same cached data. For high availability, use Redis Sentinel (automatic failover for a single primary) or Redis Cluster (automatic sharding across multiple primaries). In Python, use redis.Redis(connection_pool=pool) with a shared ConnectionPool. In Node.js, ioredis handles connection management automatically.
Related reading: Rate Limiting with Redis · Database Indexing Explained
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Caching Strategies Explained: Cache-Aside, Write-Through, Read-Through & Write-Behind (2026)
Cache-aside, read-through, write-through, write-behind — when to use each caching strategy, with Python and Node.js implementations, invalidation patterns, and cache hit-ratio metrics.
Database Indexing Explained: Why Queries Are Slow and How Indexes Fix Them
Learn how database indexes work, when to add them, and common mistakes. Covers B-tree indexes, composite indexes, EXPLAIN ANALYZE, and when indexes hurt performance.
System Design Interview: How to Design a URL Shortener like bit.ly
Complete system design walkthrough for a URL shortener like bit.ly. Covers Base62 encoding, database schema, caching with Redis, custom aliases, expiry, analytics at scale, and global distribution.