Introduction
Most teams hit a wall when MongoDB alone can't keep up with read traffic. Database queries start timing out, CPU spikes, and users see loading spinners everywhere. Caching with MongoDB and Redis: best practices explained is the approach we've learned works across production systems—combining in-memory speed with durable storage to handle the gap between raw query performance and what your business demands.
In this article, we'll walk through the patterns that actually stick. We're not talking about adding Redis and hoping it works; we're talking about designing a cache layer that scales, invalidates correctly, and doesn't hide performance problems under false confidence.
Cache Hot Reads — Product Pages and Settings in Redis
The first principle is simple: put frequently accessed, relatively static data in Redis. Product pages, user settings, feature flags, configuration objects—these are natural candidates. They're read hundreds or thousands of times per minute, they change rarely, and they live happily in memory.
Let's say we're running an e-commerce platform. A product page query might fetch the product document, reviews count, current promotions, and inventory status. Without caching, each page load hits MongoDB multiple times. With Redis, that same request resolves in microseconds.
// Node.js example using ioredis and MongoDB driver
const redis = require('ioredis');
const client = new redis();
async function getProduct(productId) {
// Try cache first
const cached = await client.get(`product:${productId}`);
if (cached) {
return JSON.parse(cached);
}
// Fall through to MongoDB
const product = await db.collection('products').findOne({
_id: new ObjectId(productId)
});
// Store in Redis for 1 hour
await client.setex(`product:${productId}`, 3600, JSON.stringify(product));
return product;
}
The TTL (time to live) matters. For product pages in a stable catalog, 1 hour is safe. For user settings that change regularly, 5 minutes might be better. Profile your actual change frequency—don't guess.
One trap teams fall into is caching at the wrong granularity. Caching the entire user object when only their subscription status changes frequently means you'll either expire the cache too often or serve stale data. Instead, cache the subscription status separately with a shorter TTL.
Invalidate on Change — Use Change Streams to Clear Cache Keys
The moment you cache something, you've made a bet about when it becomes invalid. MongoDB's change streams let you automate that bet. Instead of guessing when to invalidate, listen to actual writes.
Change streams are MongoDB's native way to watch for document mutations. When a product is updated, your change stream listener fires immediately. You can then delete the corresponding Redis keys, ensuring cache coherence without manual intervention.
// Listen to changes on the products collection
const changeStream = db.collection('products').watch([
{
$match: {
operationType: { $in: ['update', 'replace', 'delete'] }
}
}
]);
changeStream.on('change', async (change) => {
const productId = change.documentKey._id;
// Invalidate product cache
await client.del(`product:${productId}`);
// Also invalidate category page if product category changed
if (change.operationType === 'update' && change.updateDescription) {
const updatedFields = change.updateDescription.updatedFields;
if (updatedFields.category) {
await client.del(`category:${updatedFields.category}:products`);
}
}
console.log(`Invalidated cache for product ${productId}`);
});
changeStream.on('error', (error) => {
console.error('Change stream error:', error);
// Reconnect logic here
});
This approach has a critical advantage: you're reacting to real changes, not trying to guess expiration times. If a product never changes, its cache never expires and never causes stale reads. If it changes every hour, the cache stays fresh.
One consideration: change streams do add a small amount of operational overhead. In high-write scenarios (thousands of writes per second), processing every single change can strain your listener. In those cases, you might batch invalidations or use a message queue like Kafka to decouple MongoDB writes from cache invalidation.
Precompute — Store Computed Fields in MongoDB to Avoid Aggregation Per Request
Not all caching happens in Redis. Sometimes the best cache is precomputed data stored right in MongoDB alongside your primary document. This is especially valuable for aggregated or derived fields that would be expensive to calculate on every read.
Consider an order document. Calculating total price, tax, discount amount, and shipping cost on every request means running calculations or sub-queries repeatedly. Instead, compute these once when the order is created or changed, store them in the document, and they're instantly available on every read.
// When creating an order, precompute derived fields
async function createOrder(cartItems, customerId) {
const lineItems = cartItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.price,
lineTotal: item.price * item.quantity
}));
const subtotal = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
const tax = subtotal * 0.08; // Simplified
const shipping = subtotal > 100 ? 0 : 9.99;
const total = subtotal + tax + shipping;
const order = {
customerId,
lineItems,
computed: {
subtotal,
tax,
shipping,
total,
itemCount: lineItems.length,
computedAt: new Date()
},
status: 'pending',
createdAt: new Date()
};
const result = await db.collection('orders').insertOne(order);
return result;
}
The beauty here is that you've eliminated aggregation pipeline overhead. When you read an order, you have immediate access to totals. For complex calculations (e.g., customer lifetime value, average order value), precomputing even once per day in a background job can save significant query time.
The tradeoff is maintainability. If your tax calculation logic changes, you need to recalculate old documents. Use a versioning strategy: store which version of the calculation was used, and gradually recalculate stale documents in the background.
Measure — Cache Only What Profiling Shows is Slow
This is the hardest lesson to learn, and we've seen it skipped in almost every team that regrets their cache strategy later. Caching adds complexity: invalidation logic, Redis connections, serialization overhead, network latency. You should only cache what's actually slow.
Start by measuring. Use MongoDB's built-in profiling or application-level instrumentation to find which queries are taking 100ms, 500ms, or 1 second. Those are your candidates.
// Simple instrumentation wrapper
async function measuredQuery(name, fn) {
const start = Date.now();
const result = await fn();
const duration = Date.now() - start;
if (duration > 50) { // Log anything over 50ms
console.log(`Query [${name}] took ${duration}ms`);
}
return result;
}
// Usage
async function getProductWithReviews(productId) {
return measuredQuery('getProductWithReviews', async () => {
const product = await db.collection('products').findOne({ _id: productId });
const reviewCount = await db.collection('reviews').countDocuments({
productId
});
return { ...product, reviewCount };
});
}
After instrumenting for a week, you'll have clear data on what's slow. Cache only those. Don't cache a query that's already 5ms; you'll only add overhead.
Also measure cache effectiveness. Track hit rates and confirm they're high enough to justify the complexity.
// Track cache performance
const cacheStats = {
hits: 0,
misses: 0,
getHitRate() {
const total = this.hits + this.misses;
return total === 0 ? 0 : (this.hits / total * 100).toFixed(2);
}
};
async function getCachedProduct(productId) {
const cached = await client.get(`product:${productId}`);
if (cached) {
cacheStats.hits++;
return JSON.parse(cached);
}
cacheStats.misses++;
const product = await db.collection('products').findOne({ _id: productId });
await client.setex(`product:${productId}`, 3600, JSON.stringify(product));
return product;
}
A healthy cache hit rate for frequently accessed data is 80% or higher. If you're seeing 40%, either your TTL is too short, your key design is wrong, or that data shouldn't be cached.
Common Mistakes
- Caching everything just in case — Every cache entry you don't use is wasted memory and stale-data risk. Cache strategically.
- Forgetting about cascading invalidation — If you cache a user's profile and also their "profile summary" used in lists, updating the profile invalidates both. Missing one causes silent bugs.
- Using TTL as your only invalidation strategy — Relying purely on expiration times means you either serve stale data or expire too often. Combine TTL with event-driven invalidation.
- Not handling cache failures gracefully — If Redis goes down, your app should still work, just slower. Always have a fallback to MongoDB.
- Serializing large objects unnecessarily — Storing a 2MB document in Redis defeats the purpose of the speed advantage. Cache only the fields you actually need.
- Ignoring network round trips — Redis is fast, but it's not free. A round trip to Redis plus MongoDB might be slower than a single smart query. Measure both.
Key Takeaways
- Use Redis for hot reads that are relatively static, but don't over-cache; measure first.
- Pair change streams with Redis deletion to keep caches coherent without guessing expiration times.
- Precompute derived fields in MongoDB itself when they're used frequently; this eliminates aggregation overhead.
- Profile your queries before caching; you'll only add complexity for queries that actually need it.
- Track cache hit rates and latency improvements; if they're not there, remove the cache.
- Always have a fallback to MongoDB if Redis is unavailable; cache failures shouldn't cascade to application failures.






Comments
Loading comments…