If the same query runs on every request and the answer barely changes, that's compute you're paying for twice. Redis sits in front of the database for exactly this case — here's how I actually use it, past just SET and GET.
Why Redis?
Redis stores data in RAM. Reads happen in O(1) for simple keys and O(log N) for sorted sets. It supports strings, hashes, lists, sets, sorted sets, and streams — making it far more versatile than a simple key-value cache.
Cache-Aside (Lazy Loading)
The most common pattern. The application checks Redis first; on a miss it reads from the database, writes the result to Redis, and returns:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getUserById(userId: string) {
const cacheKey = `user:${userId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — query the database
const user = await User.findById(userId).lean();
if (!user) return null;
// 3. Populate cache with a TTL (e.g. 10 minutes)
await redis.set(cacheKey, JSON.stringify(user), 'EX', 600);
return user;
}
Pros: Only requested data enters the cache; cold-start is graceful. Cons: The first request for every key is always slow (cache miss).
Write-Through Caching
In write-through, you update the cache at the same time you write to the database. This ensures the cache is always warm and fresh:
async function updateUser(userId: string, updates: Partial<IUser>) {
// 1. Write to database
const user = await User.findByIdAndUpdate(userId, updates, { new: true }).lean();
// 2. Immediately update cache
const cacheKey = `user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), 'EX', 600);
return user;
}
This eliminates stale reads after mutations, but adds a write to Redis on every update — acceptable for entities that are read far more often than they are written.
Cache Invalidation
The two hardest problems in computer science: cache invalidation, naming things, and off-by-one errors. Strategies I use:
Time-Based Expiry (TTL)
Set a TTL on every key. Even if invalidation logic has a bug, stale data expires automatically:
await redis.set('settings:global', JSON.stringify(settings), 'EX', 3600);
Event-Driven Invalidation
When a mutation occurs, explicitly delete the affected keys:
async function deleteUser(userId: string) {
await User.findByIdAndDelete(userId);
await redis.del(`user:${userId}`);
}
Pattern-Based Invalidation
For clearing all keys matching a pattern (e.g., all dashboard data for a tenant):
async function invalidateTenantCache(tenantId: string) {
const keys = await redis.keys(`tenant:${tenantId}:*`);
if (keys.length > 0) {
await redis.del(...keys);
}
}
Warning:
KEYSblocks the Redis event loop on large datasets. In production, useSCANwith a cursor instead.
Distributed Locking with Redlock
When multiple pods process the same job, you need a distributed lock to prevent double-processing. The Redlock algorithm provides a safe mutual exclusion primitive:
import Redlock from 'redlock';
const redlock = new Redlock([redis], {
retryCount: 3,
retryDelay: 200,
});
async function processPayment(paymentId: string) {
const lock = await redlock.acquire([`lock:payment:${paymentId}`], 5000);
try {
// Critical section — only one pod executes this
const payment = await Payment.findById(paymentId);
if (payment.status !== 'pending') return;
await chargeStripe(payment);
payment.status = 'completed';
await payment.save();
} finally {
await lock.release();
}
}
Session Storage
Storing Express sessions in Redis makes your API stateless across pods:
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: { maxAge: 86400000 }, // 1 day
})
);
Monitoring Cache Health
Track these metrics to avoid cache-related outages:
- Hit rate — should stay above 90 %. A low hit rate means your TTLs are too short or keys are being evicted prematurely.
- Memory usage — set
maxmemoryand amaxmemory-policy(e.g.,allkeys-lru) to prevent Redis from consuming all available RAM. - Eviction count — rising evictions indicate you need more memory or smarter TTLs.
Wrapping Up
Cache-aside with a TTL covers most cases — start there before reaching for write-through or distributed locks. And keep an eye on hit rate; a cache nobody's hitting is just extra infrastructure to maintain.
