Caching expensive results in Redis is one of the most effective ways to make a web application faster: a database query that takes 50 ms becomes a sub-millisecond lookup. But caching brings its own problems: stale data, thundering herds, memory limits. Here are the patterns that work.
What to cache
- Results of slow or frequent database queries (product lists, dashboards, counts).
- Rendered fragments or whole pages for anonymous users.
- Responses from slow third-party APIs (exchange rates, geolocation).
- Computed values (recommendations, aggregates).
- Sessions and rate-limit counters (strictly speaking, state rather than cache).
Don’t cache data that must always be exact (account balances during payment), or data that is cheap to compute.
Cache-aside (lazy loading): the default pattern
The application checks the cache first, and loads from the database on a miss:
import json, redis
r = redis.Redis(decode_responses=True)
def get_product(product_id):
key = f"product:{product_id}:v1"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
product = db.fetch_product(product_id) # slow path
r.set(key, json.dumps(product), ex=600) # cache for 10 minutes
return product
Only requested data gets cached, and a Redis outage just makes things slower (if you handle connection errors), not broken.
Other patterns
| Pattern | How | Trade-off |
|---|---|---|
| Cache-aside | App reads cache, fills on miss | Simple; first request is slow |
| Write-through | App writes DB and cache together | Fresh cache; more write work |
| Write-behind | Write cache, flush to DB later | Fast writes; risk of data loss |
| Refresh-ahead | Refresh popular keys before expiry | No slow misses; more complexity |
TTLs: always set one
Every cache key should expire. TTLs bound staleness, and keep memory in check:
- seconds to minutes for fast-changing data (stock levels, feeds);
- hours for slow-changing data (product details, settings);
- add jitter (a random ±10%) so many keys created together don’t expire at the same moment.
import random
r.set(key, value, ex=600 + random.randint(-60, 60))
Invalidation
“There are only two hard things in computer science: cache invalidation and naming things.” Practical approaches:
- Delete on write: when a product changes,
DEL product:42:v1. Next read reloads it. Simple and reliable. - Versioned keys: include a version in the key (
product:42:v7), and bump the version on change. Old keys simply expire. - Short TTLs where some staleness is acceptable, and no invalidation logic at all.
- For groups (all keys of a category), store a generation number (
category:5:gen), and include it in keys.
Avoid KEYS pattern to find and delete keys in production: it blocks Redis. Use SCAN if you must iterate. See Redis CLI commands.
Cache stampede protection
When a popular key expires, hundreds of requests can miss at once, and all hit the database. Mitigations:
- Lock on miss: the first request sets a short lock (
SET lock:key 1 NX EX 10), and rebuilds; the others wait briefly, or serve stale data. - Stale-while-revalidate: store the value with a soft expiry inside it, serve stale data while one worker refreshes.
- TTL jitter, as above.
Key design and serialization
- Use readable, namespaced keys:
app:product:42:v1. - Serialize with JSON (portable), or MessagePack (smaller, faster).
- Keep values reasonably small. Values of many megabytes slow down Redis, which processes commands one at a time.
- Use hashes for objects whose fields you update separately.
Memory and eviction
Configure Redis as a cache explicitly:
# /etc/redis/redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru # or allkeys-lfu: evict least frequently used
With these settings, Redis evicts old keys instead of failing writes when memory is full. Use a separate Redis instance (or database with noeviction) for data that must not be evicted, such as queues and sessions. See Redis maxmemory and eviction.
Persistence is usually unnecessary for a pure cache: disabling RDB and AOF saves disk I/O. See Redis persistence: RDB and AOF.
Framework integration
Most frameworks have Redis cache backends:
| Framework | Integration |
|---|---|
| WordPress | Redis Object Cache plugin. See Redis for WordPress |
| Laravel | CACHE_STORE=redis |
| Django | django.core.cache.backends.redis.RedisCache |
| Node.js | ioredis / node-redis with your own helpers |
| Rails | :redis_cache_store |
Measure the hit rate
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'
Hit rate = hits / (hits + misses). Below about 80% usually means TTLs are too short, keys are too specific, or the cache is too small (check evicted_keys).
Quick reference
| Topic | Advice |
|---|---|
| Pattern | Cache-aside by default |
| Expiry | Always TTL, plus jitter |
| Invalidation | Delete on write or versioned keys |
| Stampedes | Lock on miss, stale-while-revalidate |
| Memory | maxmemory + allkeys-lru/lfu |
| Monitoring | Hit rate, evictions, memory |
Related: Redis vs Memcached and installing Redis on Ubuntu.