If your Node.js API keeps hitting the database for the same data on every request, you are burning compute cycles and adding latency that your users can feel. Redis sits between your application and your database as an in-memory data store, slashing response times from hundreds of milliseconds to single-digit milliseconds.
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: KEYS blocks the Redis event loop on large datasets. In production, use SCAN with 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.
Key Takeaways
- Cache-aside is the safest starting point for most read-heavy workloads.
- Pair every cache write with a TTL — stale data is worse than a cache miss.
- Use Redlock when multiple workers compete for the same resource.
- Monitor hit rates and evictions so caching remains a net benefit, not a liability.
Redis is deceptively simple to set up but requires discipline to operate well. Apply these strategies, and your APIs will feel instantaneous.
