Introduction
Redis is one of the most practical tools we reach for when a service starts feeling slow—but caching with Redis in NestJS requires intentionality, not just decorators. The difference between a cache that improves performance and one that creates silent data bugs often comes down to how you register it, when you invalidate it, and which unit system you're using for expiry.
In this guide, we'll walk through caching with Redis in NestJS: what developers should know, covering the setup patterns we've used across production e-commerce, SaaS, and ride-hailing platforms. We'll focus on explicit cache-aside logic, deliberate invalidation, and the upgrade gotchas that have caught teams off guard.
Setting up CacheModule with Redis for multi-instance setups
When you're running multiple instances of your NestJS service (and you should be in production), an in-memory cache is useless. Each instance has its own memory, so invalidating cache on one machine doesn't help the others. Redis solves this by giving you a shared store.
NestJS's CacheModule makes this straightforward. Register it once at the root level and inject it wherever you need it:
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
import { Module } from '@nestjs/common';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
store: redisStore,
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
ttl: 300_000, // 5 minutes in milliseconds (v5+ default)
}),
],
})
export class AppModule {}Setting isGlobal: true means you won't need to re-import CacheModule in every feature module. The ttl parameter sets a default expiry, but you'll override this per key in most cases based on how stale that data can afford to be.
For production deployments, consider using a connection pool or sentinel mode if your Redis is clustered:
import { RedisClientOptions } from 'redis';
CacheModule.register({
isGlobal: true,
store: redisStore,
// Use connection pool for high concurrency
nodes: [
{ host: 'redis-1.internal', port: 6379 },
{ host: 'redis-2.internal', port: 6379 },
],
// or for sentinel
sentinels: [
{ host: 'sentinel-1', port: 26379 },
{ host: 'sentinel-2', port: 26379 },
],
name: 'mymaster', // Sentinel master name
})Once registered globally, any service can inject the CACHE_MANAGER token and start using it.
Cache-aside in services: explicit get and set over magic
The cache-aside pattern (also called "lazy loading") is where you check the cache first, and if there's a miss, you fetch from the source and repopulate the cache. It's explicit, predictable, and gives you full control.
Some teams reach for decorators like @Cacheable() because they look clean, but decorators hide the logic and make invalidation patterns hard to reason about. We prefer writing it out in the service:
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { PrismaService } from './prisma.service';
interface Product {
id: string;
name: string;
price: number;
updatedAt: Date;
}
@Injectable()
export class ProductService {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
private prisma: PrismaService,
) {}
async getProduct(id: string): Promise {
const key = `product:${id}`;
// Try cache first
const cached = await this.cache.get(key);
if (cached) {
return cached;
}
// Cache miss: fetch from database
const product = await this.prisma.product.findUnique({
where: { id },
});
if (!product) {
return null;
}
// Repopulate cache (5 minutes in milliseconds)
await this.cache.set(key, product, 300_000);
return product;
}
}Why is this better? You can see exactly when we check, when we fetch, and when we cache. If debugging is needed, there's no magic to trace through. The TTL is explicit and in the same place you're doing the caching.
Invalidate on write: deleting keys when data changes
A cache is only useful if it's fresh enough. The moment a product is updated in the database, the cached version is stale and dangerous. You must delete it.
The pattern is simple: whenever you write to the source of truth, delete the cache key:
@Injectable()
export class ProductService {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
private prisma: PrismaService,
) {}
async updateProduct(
id: string,
data: Partial,
): Promise {
// Update the database
const updated = await this.prisma.product.update({
where: { id },
data,
});
// Invalidate the cache immediately
const key = `product:${id}`;
await this.cache.del(key);
return updated;
}
async deleteProduct(id: string): Promise {
await this.prisma.product.delete({
where: { id },
});
// Clean up the cache
const key = `product:${id}`;
await this.cache.del(key);
}
async listProducts(categoryId: string): Promise {
const key = `products:category:${categoryId}`;
const cached = await this.cache.get(key);
if (cached) {
return cached;
}
const products = await this.prisma.product.findMany({
where: { categoryId },
});
await this.cache.set(key, products, 600_000); // 10 minutes
return products;
}
async updateProductCategory(id: string, categoryId: string) {
const product = await this.prisma.product.findUnique({
where: { id },
});
// Invalidate the individual product cache
await this.cache.del(`product:${id}`);
// Invalidate any category lists that might include this product
await this.cache.del(`products:category:${product.categoryId}`);
await this.cache.del(`products:category:${categoryId}`);
return await this.prisma.product.update({
where: { id },
data: { categoryId },
});
}
}Notice how we invalidate not just the individual product key, but also any list keys that might be affected. This gets complex fast, especially with related data. Some teams use cache tags or namespaces to group related keys and clear them together:
async invalidateProductRelated(productId: string, oldCategoryId?: string) {
// Delete all keys matching a pattern
// Note: your Redis client needs to support SCAN for this
const keys = await this.redis.keys(`product:${productId}:*`);
if (keys.length) {
await this.cache.del(...keys);
}
// Also clear category caches
if (oldCategoryId) {
await this.cache.del(`products:category:${oldCategoryId}`);
}
}Mind the TTL units: milliseconds in cache-manager v5+
This is the gotcha that catches everyone after an upgrade. In cache-manager v5+, TTL is in milliseconds, not seconds.
If you upgrade from v4 to v5 without changing your code, a key you intended to cache for 5 minutes will expire in 5 milliseconds. You won't see an obvious error—the cache will just never hit, and your database will get hammered.
Pre-v5 code:
// v4: seconds
await this.cache.set(key, value, 300); // 5 minutesPost-v5 code:
// v5+: milliseconds
await this.cache.set(key, value, 300_000); // 5 minutes (300,000 ms)The fix: use constants to avoid magic numbers and make the intention clear:
export const CACHE_TTL = {
PRODUCT: 5 * 60 * 1000, // 5 minutes
INVENTORY: 1 * 60 * 1000, // 1 minute
USER_SESSION: 30 * 60 * 1000, // 30 minutes
CATEGORY_LIST: 10 * 60 * 1000, // 10 minutes
} as const;
// Usage
await this.cache.set(key, value, CACHE_TTL.PRODUCT);You can also set a default TTL in your CacheModule config and only override when needed. But being explicit is safer.
Common mistakes
- Forgetting to invalidate related keys: You cache a product, then cache a category listing that includes it. When the product changes, you delete the product key but leave the stale listing. The listing serves out-of-date data. Always trace your read paths and invalidate proactively.
- Caching null or error states: A query fails, you cache
null, and now every request gets the cachednullinstead of retrying the database. Either don't cache misses, or use a much shorter TTL for them (e.g., 30 seconds instead of 5 minutes). - Not handling connection failures: If Redis goes down, your cache operations will throw. Wrap cache calls in try-catch or use a fallback. Missing cache shouldn't break the feature.
- Caching mutable objects without serialization: If you cache a JavaScript object and later mutate it in memory, the cached copy is also mutated. Store immutable snapshots or clone on retrieval.
- TTL unit confusion after upgrades: As mentioned, v5+ uses milliseconds. Always check your version and test after upgrading.
Key takeaways
- Register
CacheModulewithisGlobal: trueand a Redis store once at the application root, not per-module. - Use the cache-aside pattern with explicit get and set logic in your services, not decorators—you need to see and control when caching happens.
- Invalidate cache keys aggressively whenever you write to the source of truth. Trace all read paths that depend on that data and invalidate them too.
- In cache-manager v5+, TTL is in milliseconds. Use named constants to avoid the post-upgrade gotcha.
- Don't cache errors, nulls, or mutable state without careful consideration. Consider the cost of a stale answer versus a miss.
- Always wrap cache operations in error handling—Redis going down should degrade gracefully, not crash your service.






Comments
Loading comments…