Adding Redis caching naively creates new problems: stale data, stampedes, and cache invalidation bugs. Here's how to do it correctly.
Setup
import { createClient } from 'redis';
const redis = createClient({
url: process.env.REDIS_URL,
socket: {
reconnectStrategy: (retries) => Math.min(retries * 50, 2000)
}
});
redis.on('error', (err) => console.error('Redis error:', err));
await redis.connect();
Cache-Aside Pattern (Most Common)
async function getUserById(id: string) {
const cacheKey = `user:${id}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Miss: fetch from DB
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!user) return null;
// 3. Store in cache with TTL
await redis.setEx(cacheKey, 3600, JSON.stringify(user)); // 1 hour
return user;
}
Cache Stampede Prevention
When a cached item expires, many concurrent requests hit the DB simultaneously. Fix this with probabilistic early expiration or a lock:
async function getWithLock<T>(
key: string,
fetch: () => Promise<T>,
ttl: number = 300
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', {
NX: true, // Only set if not exists
EX: 10 // Auto-release after 10s
});
if (!lockAcquired) {
// Another process is fetching -- wait and retry
await new Promise(r => setTimeout(r, 100));
return getWithLock(key, fetch, ttl);
}
try {
const value = await fetch();
await redis.setEx(key, ttl, JSON.stringify(value));
return value;
} finally {
await redis.del(lockKey);
}
}
Write-Through Cache
When you write to DB, immediately update the cache:
async function updateUser(id: string, data: Partial<User>) {
// Update database
const updated = await db.query(
'UPDATE users SET ... WHERE id = $1 RETURNING *',
[id, ...Object.values(data)]
);
// Immediately update cache
const cacheKey = `user:${id}`;
await redis.setEx(cacheKey, 3600, JSON.stringify(updated));
return updated;
}
Cache Invalidation
For complex invalidation, use tags:
// Tag-based invalidation
async function cacheWithTags(key: string, value: any, tags: string[], ttl: number) {
await redis.setEx(key, ttl, JSON.stringify(value));
// Store key in each tag's set
for (const tag of tags) {
await redis.sAdd(`tag:${tag}`, key);
await redis.expire(`tag:${tag}`, ttl + 60);
}
}
async function invalidateTag(tag: string) {
const keys = await redis.sMembers(`tag:${tag}`);
if (keys.length) await redis.del([...keys, `tag:${tag}`]);
}
// Usage
await cacheWithTags(`user:${id}`, user, [`user:${id}`, 'users'], 3600);
// Invalidate when user's account changes
await invalidateTag(`user:${id}`);
Rate Limiting
async function rateLimit(identifier: string, limit: number, windowSec: number): Promise<boolean> {
const key = `ratelimit:${identifier}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSec);
}
return current <= limit;
}
// Middleware
app.use(async (req, res, next) => {
const allowed = await rateLimit(
req.ip,
100, // 100 requests
60 // per 60 seconds
);
if (!allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
Session Storage
import session from 'express-session';
import { createClient } from 'redis';
import connectRedis from 'connect-redis';
const RedisStore = connectRedis(session);
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
Monitoring
// Check Redis memory usage
const info = await redis.info('memory');
const memUsed = info.match(/used_memory_human:(\S+)/)?.[1];
const maxMem = info.match(/maxmemory_human:(\S+)/)?.[1];
console.log(`Redis memory: ${memUsed} / ${maxMem}`);
// Monitor hit rate
const stats = await redis.info('stats');
const hits = stats.match(/keyspace_hits:(\d+)/)?.[1];
const misses = stats.match(/keyspace_misses:(\d+)/)?.[1];
const hitRate = Number(hits) / (Number(hits) + Number(misses));
console.log(`Cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
Target hit rate above 80%. Below that, you're paying for Redis without getting the benefit. Check if your TTLs are too short or your cache keys are too granular.