Redis is still the caching default in 2026, and its fundamental patterns haven't changed — but the mistakes teams make are consistent enough to be worth documenting. Missing TTLs fill memory until the node OOMs. No invalidation strategy serves stale data for hours. Stampedes on cache warm-up crush databases. This guide covers the right patterns.
What changed in 2026
- Redis 8 (released 2025) added a hash-based string encoding that reduces memory usage by ~10% for typical key sets, and improved replication throughput.
- Valkey (the Redis fork from the Linux Foundation, post-license change) is available in most managed offerings (ElastiCache, Upstash) and is API-compatible.
- Dragonfly is a Redis-compatible in-memory store with multi-threaded architecture; worth considering for single-node setups that need >100k ops/s.
- Upstash (serverless Redis over HTTP) is the dominant choice for edge and serverless deployments where persistent TCP connections are impractical.
The cache-aside pattern
Cache-aside (lazy loading) is the right default. The application manages the cache explicitly:
// Node.js with ioredis
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
async function getUserProfile(userId: string) {
const key = `user:${userId}:profile`;
// 1. Try cache
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// 2. Miss — fetch from DB
const user = await db.users.findUnique({ where: { id: userId } });
if (!user) return null;
// 3. Write to cache with TTL
await redis.set(key, JSON.stringify(user), 'EX', 300); // 5 minutes
return user;
}
On mutation:
async function updateUserProfile(userId: string, data: Partial<User>) {
await db.users.update({ where: { id: userId }, data });
await redis.del(`user:${userId}:profile`); // invalidate
}
Key naming conventions
<entity>:<id>:<data-type>
user:123:profile
user:123:permissions
post:456:body
feed:user:123:page:1
rate_limit:ip:192.168.1.1
session:abc123
Never: profile_123, userprofile123, UP_123. Namespacing lets you:
- Delete all keys for a user:
redis.del with a collected list
- Scan for a prefix:
SCAN 0 MATCH user:123:* (avoid in production hot paths)
- Reason about memory usage per entity type
TTL strategy
| Data type |
TTL |
Reasoning |
| User profile |
5–15 min |
Changes infrequently; stale is tolerable briefly |
| Session |
30 min (sliding) |
Expire idle sessions |
| Rate limit counters |
1 min (fixed) |
Must be accurate; short window |
| Rendered HTML pages |
60 s–10 min |
High read, low write; stale is okay |
| API response cache |
30–300 s |
Depends on data freshness requirement |
| Expensive DB query |
60–600 s |
Match data change frequency |
Add ±10% jitter:
const BASE_TTL = 300;
const jitter = Math.floor(Math.random() * BASE_TTL * 0.1);
await redis.set(key, value, 'EX', BASE_TTL + jitter);
Preventing cache stampede
When a popular key expires, many concurrent requests all miss and hammer the DB simultaneously.
Option 1: probabilistic early expiration
async function getCachedWithPER<T>(
key: string,
ttl: number,
fetch: () => Promise<T>,
beta = 1,
): Promise<T> {
const raw = await redis.get(key);
if (raw) {
const { value, expireAt } = JSON.parse(raw) as { value: T; expireAt: number };
const remaining = (expireAt - Date.now()) / 1000;
// Probabilistically recompute before expiry
if (remaining > 0 && remaining > -beta * Math.log(Math.random())) {
return value;
}
}
const value = await fetch();
await redis.set(key, JSON.stringify({ value, expireAt: Date.now() + ttl * 1000 }), 'EX', ttl);
return value;
}
Option 2: mutex lock on miss
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!acquired) {
await sleep(100); // brief wait for lock holder to populate
return getCached(key); // retry
}
try {
const value = await fetch();
await redis.set(key, JSON.stringify(value), 'EX', ttl);
return value;
} finally {
await redis.del(lockKey);
}
Invalidation patterns
| Pattern |
How |
Best for |
| Delete on write |
DEL key after mutation |
Simple, cache-aside |
| Tag-based |
Tag key with entity ID, delete by tag |
Complex objects with shared data |
| Write-through |
Write to cache and DB in same step |
Strongly consistent reads |
| TTL-only |
Just let it expire |
Tolerant of short staleness |
Avoid FLUSHALL or FLUSHDB in production — it clears everything including sessions and rate limit state.
Common mistakes
No TTL on cache writes. Every SET must have an EX or PX option. One unbounded key is a memory leak.
Caching objects with circular references. JSON.stringify throws; normalize to plain objects before caching.
Using KEYS * in production. It blocks Redis while scanning. Use SCAN with a cursor for inspection.
Caching null as a miss. A negative cache entry (caching "this ID doesn't exist") with a short TTL prevents repeated DB hits for missing resources. Not caching null means every missing-ID request hits the DB.
Storing large blobs. Redis values above ~1 MB cause network and memory pressure. Compress or split large payloads, or store in object storage and cache only the URL.
What to skip
- Write-back caching (write to cache only, async flush to DB) — the risk of data loss on cache node failure is rarely worth the write throughput gain for typical web apps.
- Caching deeply nested objects that change partially — cache at the leaf level or use a tag-based strategy; updating a nested field invalidates the whole key.
- Redis as a primary database — Redis persistence (RDB/AOF) is not a replacement for a relational DB; use it as a cache layer on top of Postgres.
FAQ
Redis or Valkey in 2026?
For managed deployments (ElastiCache, Upstash), Valkey and Redis are API-identical. Pick based on your cloud provider's offering. For self-hosted, either is fine.
How do I cache in Python?
Use redis-py (sync) or redis.asyncio (async). Pattern is identical: get → miss → fetch DB → setex.
Should I use a Redis cluster for caching?
Only if your dataset exceeds a single node's memory (~tens of GBs) or you need >500k ops/s. A single well-sized node handles most web app cache loads.
How do I debug what is in Redis?
Use redis-cli --scan --pattern 'user:*' to list keys by namespace, and DEBUG OBJECT <key> to inspect encoding and idle time. In production, use a tool like RedisInsight.
Where to go next
See How to set up Redis caching in 2026, How to rate limit an API in 2026, and How to set up Postgres locally in 2026.