Verdict up front: Most apps that add Redis don't need it. A 10MB in-process LRU cache (Python functools.lru_cache, Node lru-cache) gets you 80% of the benefit at 0% of the operational cost. Reach for Redis only when (a) you need to share cache across multiple instances, (b) your working set is larger than per-instance memory, or (c) you need pub/sub or distributed locks.
Why "cache it in Redis" is the default and often wrong
The reflex to add Redis comes from web tutorials that assume distributed workloads. Most real apps run 2-5 instances, share-nothing, and would be fine with local memory.
Latency comparison for a 1KB lookup:
- L1 cache hit (CPU): 1ns
- In-process dict / map: 100ns
- Local Redis (Unix socket): 50μs
- Redis over network (same AZ): 500μs
- Redis over network (cross-region): 50ms+
An in-process cache is 5,000x faster than network Redis. The only reasons to give that up are sharing across instances or working sets bigger than per-instance RAM.
Eviction policies, plainly
- noeviction — write fails when memory is full. Default. Use for queues, never for caches.
- allkeys-lru — evict least-recently-used across all keys. Use this for general-purpose caches.
- allkeys-lfu — evict least-frequently-used. Better when access pattern is skewed (10% of keys get 90% of reads).
- allkeys-random — evict random keys. Faster than LRU/LFU, useful when access pattern is uniform.
- volatile-lru — evict LRU among keys with TTL only. Use when you mix cache and durable data in one Redis.
- volatile-lfu — like above but LFU.
- volatile-ttl — evict keys closest to expiration first.
Default for new Redis instances: maxmemory-policy allkeys-lru. This is the single most common misconfiguration — leaving it on noeviction and watching writes fail at the worst moment.
Cache-aside (lazy loading)
The pattern that fits 90% of use cases:
def get_user(user_id):
cached = redis.get(f"user:{user_id}")
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis.setex(f"user:{user_id}", 300, json.dumps(user)) # 5 min TTL
return user
Properties:
- Read path tolerates Redis being down (falls through to DB)
- Cache populated on demand, no warm-up
- Stale reads possible for up to TTL (acceptable for most data)
- Cache stampede risk: 100 concurrent requests for a cold key all hit the DB. Mitigate with a 1-key lock or probabilistic early expiration.
Write-through
Every write updates Redis and DB atomically (or with retry/rollback). Used when stale reads aren't tolerable.
def update_user(user_id, data):
db.update("UPDATE users SET ... WHERE id = %s", user_id)
redis.setex(f"user:{user_id}", 3600, json.dumps(data))
Risk: DB write succeeds, Redis write fails — cache now stale. Use Redis pipelines + transactions to make this atomic, or use cache-aside with a shorter TTL.
Write-behind (write-back)
Writes go to Redis, then async to DB. Higher throughput, risk of data loss on crash.
Rarely needed in 2026. Only justified when DB write latency is the bottleneck and you can tolerate losing the last few seconds of writes. Most teams that try this regret it within 6 months.
TTL strategy
Common mistakes:
- TTL too short: 30 seconds means every page load is a cache miss. You're paying Redis bills for nothing.
- TTL too long: 24 hours means data changes don't propagate. Users see stale prices, stale notifications.
- Same TTL for everything: User profiles (slow-changing) and live order status (fast-changing) should not share a TTL.
Rule of thumb: TTL = (1 / change frequency) × 0.5. If user profiles change once a week, cache for ~3 days. If prices change every 5 minutes, cache for ~90 seconds.
Cache hit ratio: what's good?
- <50% hit ratio: Redis is hurting you, not helping. Latency + cost without benefit.
- 50-80%: Acceptable, but tune TTL or eviction policy.
- >90%: Healthy. Don't optimise further.
Check with: redis-cli INFO stats | grep keyspace → look at keyspace_hits / (keyspace_hits + keyspace_misses).
When Redis isn't the right tool
| Use case | Better option |
|---|---|
| Cache for a single-instance app | Local LRU (functools.lru_cache, lru-cache npm) |
| Persistent message queue | RabbitMQ, AWS SQS, Cloudflare Queues |
| Full-text search | Postgres tsvector, Meilisearch, ElasticSearch |
| Time-series data | TimescaleDB, InfluxDB, ClickHouse |
| Cross-region cache | Cloudflare KV, DynamoDB Global Tables |
| Counter increments only | Postgres + UPSERT works fine to ~10k/sec |
Pricing
- Self-hosted on a DO droplet: €6/month for 1GB Redis
- AWS ElastiCache (cache.t4g.micro): $16/month
- Upstash serverless: €0 + per-request ($0.20 per 100k commands, 256MB free)
- Redis Cloud (free tier): 30MB free, $7/month for 250MB
For low-volume side projects: Upstash. For production with consistent load: ElastiCache or self-hosted. For learning: a 1GB Docker container locally.
Cache layer not paying off?
We audit Redis usage and find the 80% of keys that shouldn't be in Redis at all — either move to local cache or skip caching entirely. Typical bill cut: 40-60%.
Book a discovery call