Redis Caching Patterns Explained: Cache-Aside, TTL, and Invalidation
Introduction
Redis is an in-memory data store used as cache, session store, rate limiter, and message broker. As a cache, it sits between application and slow data sources (PostgreSQL, S3, external APIs)—cutting latency from milliseconds to sub-millisecond and reducing database load by orders of magnitude.
Choosing the right caching pattern (cache-aside, read-through, write-through) and invalidation strategy determines whether your cache improves reliability or causes stale data bugs. This guide explains patterns, TTL design, hot keys, eviction, and production monitoring.
Table of Contents
- Why Redis for Caching
- Cache-Aside Pattern
- Read-Through and Write-Through
- TTL Strategies
- Cache Invalidation
- Hot Keys and Stampede Protection
- Redis Data Structures for Cache
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
Why Redis for Caching
Compared to application-memory cache:
- Shared across all app instances
- Persistence options (RDB, AOF) for warm restarts
- Rich structures: strings, hashes, sorted sets
- Atomic operations, TTL per key
Typical stack:
Client → API → Redis (cache) → PostgreSQL (source of truth)
Cache hit ratio above 90% on read-heavy workloads dramatically cuts DB CPU.
Cache-Aside Pattern
Application manages cache explicitly—most common pattern:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const TTL_SECONDS = 300;
async function getUser(userId: string) {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const user = await db.users.findById(userId);
if (!user) return null;
await redis.setex(cacheKey, TTL_SECONDS, JSON.stringify(user));
return user;
}
On write, update DB then delete cache (or update cache):
async function updateUser(userId: string, data: UpdateUserDto) {
const user = await db.users.update(userId, data);
await redis.del(`user:${userId}`);
return user;
}
Read-Through and Write-Through
Read-through: cache library loads from DB on miss—app only talks to cache layer.
Write-through: writes go to cache and DB synchronously—cache always warm, higher write latency.
Write-behind (write-back): writes to cache first, async flush to DB—fast writes, risk of data loss on crash.
Cache-aside gives app control; read/write-through suits dedicated cache products (some CDN edge caches).
TTL Strategies
TTL prevents infinite staleness when invalidation misses:
// Fixed TTL — simple
await redis.setex(key, 3600, value);
// Jitter — prevent synchronized expiry
const ttl = 3600 + Math.floor(Math.random() * 300);
// Per-entity TTL — hot data shorter
const ttl = user.isPremium ? 60 : 300;
No TTL only when invalidation is guaranteed on every mutation—risky for bug tolerance.
Document max staleness SLA for product (e.g. "profile may lag 5 minutes").
Cache Invalidation
Strategies:
- Delete on write — simplest; brief miss after update
- Update cache on write — no miss; race if write order wrong
- Tag-based invalidation —
tag:postsmaps to many keys - Pub/Sub broadcast — all nodes invalidate local + Redis
async function invalidatePostCaches(postId: string) {
const keys = await redis.smembers(`post:${postId}:cache_keys`);
if (keys.length) await redis.del(...keys);
}
Version keys:
const key = `user:${userId}:v${await getSchemaVersion()}`;
Bump version instead of deleting thousands of keys.
Hot Keys and Stampede Protection
Hot key: one key (viral product, celebrity profile) overwhelms single Redis shard.
Mitigations:
- Local L1 cache with short TTL in app memory
- Replicate hot key across shards with random read selection
- Request coalescing (singleflight):
const inflight = new Map<string, Promise<User>>();
async function getUserCoalesced(id: string) {
if (inflight.has(id)) return inflight.get(id)!;
const promise = getUser(id).finally(() => inflight.delete(id));
inflight.set(id, promise);
return promise;
}
Cache stampede: TTL expires, thousands of requests hit DB simultaneously. Fix with probabilistic early expiration or mutex lock in Redis:
const lock = await redis.set(`lock:${key}`, "1", "EX", 10, "NX");
if (lock) {
const data = await fetchFromDb();
await redis.setex(key, TTL, JSON.stringify(data));
}
Redis Data Structures for Cache
# String — JSON blob
SET user:1 "{\"name\":\"Monu\"}" EX 300
# Hash — partial field updates
HSET product:99 name "Widget" price "19.99"
EXPIRE product:99 600
# Sorted set — leaderboards
ZADD leaderboard 100 "user:1"
# JSON module (Redis 7+)
JSON.SET doc:1 $ '{"title":"Post"}'
Choose structure matching access pattern—hashes for field-level updates without full JSON parse.
Best Practices
- Namespace keys —
app:env:entity:id. - Always set TTL unless strong invalidation + monitoring.
- Monitor hit rate, memory, evictions — CloudWatch, Redis INFO.
- Serialize consistently — JSON with stable field order for debugging.
- Graceful degradation — if Redis down, fall back to DB (circuit breaker).
- Size limits — don't cache huge payloads; compress if needed.
Common Mistakes
- Cache without invalidation plan — permanent stale data.
- Caching errors/null without short TTL — "not found" cached forever.
- Same TTL for all keys — thundering herd on expiry.
- No maxmemory policy — OOM kills Redis (
allkeys-lrucommon). - Trusting cache for auth — always verify permissions at request time.
- KEYS * in production — blocks Redis; use SCAN.
Interview Questions
- Explain cache-aside pattern step by step.
- Cache-aside vs read-through?
- How to prevent cache stampede?
- Strategies for cache invalidation?
- What is a hot key problem?
- When not to use Redis cache?
- Redis eviction policies?
- How to handle Redis failure?
Redis Cluster and High Availability
Production Redis often runs as Redis Cluster or managed ElastiCache with replication:
Primary → Replica (read scaling, failover)
Sentinel or managed failover promotes replica on primary failure
Clients must handle MOVED/ASK redirects in cluster mode—use cluster-aware clients (ioredis Cluster mode).
Cache aside during failover: brief window where new primary has empty cache—expect DB spike; circuit breakers protect database.
Session Store Pattern
Redis as session backend shares patterns with cache-aside:
import session from "express-session";
import RedisStore from "connect-redis";
app.use(
session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, maxAge: 86400000 },
})
);
Session keys expire via TTL—align cookie maxAge with Redis TTL to avoid orphaned sessions.
Monitoring Redis Health
Key metrics:
used_memoryvsmaxmemoryevicted_keysrateinstantaneous_ops_per_sec- Replication lag on replicas
- Slowlog entries
Alert on memory above 80% and rising eviction rate—cache hit ratio will collapse soon after.
Cache Warming and Preloading
After deploy or cache flush, cold cache causes thundering herd on database:
async function warmCache() {
const hotProductIds = await analytics.getTopProducts(100);
await Promise.all(hotProductIds.map((id) => getProduct(id)));
}
// Run on server start or after maintenance
warmCache().catch(console.error);
Warm critical keys during low-traffic windows. Coordinate with CDN cache purge—double cold start if both empty simultaneously.
Redis Persistence Tradeoffs
| Mode | Durability | Performance |
|---|---|---|
| No persistence | Cache only—data loss on restart | Fastest |
| RDB snapshots | Point-in-time, minutes gap | Good |
| AOF everysec | Seconds of loss possible | Moderate |
Session stores often use AOF; pure cache can disable persistence entirely for speed.
Summary
Redis caching accelerates reads when patterns match workload. Cache-aside with delete-on-write and sensible TTLs is the default starting point. Add stampede protection, hot key mitigation, and monitoring before scale bites. Treat cache as optional acceleration—not source of truth—unless you explicitly design for it.