Redis Cache Store
The default in-memory cache that ships with @nestjs/cache-manager lives inside a single Node process, which means a value cached on one instance is invisible to every other instance and is lost the moment that process restarts. Once you scale horizontally behind a load balancer, that becomes a correctness problem: requests bounce between nodes that each hold a different, stale view of the cache. Swapping the in-memory store for Redis gives every instance a single shared cache, survives restarts and deploys, and lets you reason about TTLs and invalidation centrally.
Why a distributed store matters
CacheModule is store-agnostic. Whatever object you pass as store simply has to implement cache-manager’s get/set/del contract, so moving from memory to Redis is a configuration change rather than a code change in your services. The benefits compound at scale:
- A cache hit on instance A is also a hit on instances B and C.
- Restarts, rolling deploys, and autoscaling no longer cold-start an empty cache.
- TTLs and explicit invalidation are coordinated in one place, not duplicated per process.
- Memory pressure moves off your application heap and onto Redis, where eviction is tunable.
Installing the Redis store
Modern cache-manager (v5+) uses Keyv adapters under the hood. The recommended pairing for NestJS 10/11 is @keyv/redis plus cacheable, which together give you a robust Redis-backed store.
npm install @nestjs/cache-manager cache-manager @keyv/redis keyv cacheable
Note: Older guides use
cache-manager-redis-storeorcache-manager-ioredis. Those target cache-manager v4 and do not work with the v5 API that current@nestjs/cache-managerdepends on. Prefer the Keyv-based setup below.
Registering Redis asynchronously
Connection details belong in configuration, not in source, so register the module with registerAsync and inject ConfigService. The stores array accepts a Keyv instance wrapping the Redis adapter; the namespace becomes the key prefix Redis sees, which keeps multiple apps (or environments) from colliding on the same Redis instance.
// src/cache/cache.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createKeyv } from '@keyv/redis';
import Keyv from 'keyv';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const url = config.getOrThrow<string>('REDIS_URL');
const keyv = new Keyv({
store: createKeyv(url),
namespace: 'devcraftly', // becomes the Redis key prefix
ttl: 30_000, // default TTL in milliseconds
});
keyv.on('error', (err) => {
// Never let a transient Redis error crash the process.
console.error('Redis cache error', err);
});
return { stores: [keyv] };
},
}),
],
})
export class AppCacheModule {}
With isGlobal: true, the CACHE_MANAGER provider is available everywhere without re-importing the module. The keyv.on('error', ...) handler is important: a Redis blip should degrade you to cache misses, not unhandled rejections.
Using the shared cache in a service
Inject CACHE_MANAGER and the API is identical to the in-memory store — that is the whole point. Here a service caches an expensive lookup; the cached value is now visible to every instance.
// src/products/products.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ProductsService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async getProduct(id: string) {
const key = `product:${id}`;
const cached = await this.cache.get<{ id: string; name: string }>(key);
if (cached) {
return cached;
}
const product = await this.loadFromDatabase(id);
// Per-call TTL (ms) overrides the module default.
await this.cache.set(key, product, 60_000);
return product;
}
async invalidate(id: string) {
await this.cache.del(`product:${id}`);
}
private async loadFromDatabase(id: string) {
await new Promise((r) => setTimeout(r, 200));
return { id, name: 'Mirrorless Camera' };
}
}
Inspecting Redis directly shows the namespaced key and the JSON-serialized value cache-manager wrote.
redis-cli KEYS 'devcraftly*'
redis-cli GET 'devcraftly:product:42'
Output:
1) "devcraftly:product:42"
{"value":{"id":"42","name":"Mirrorless Camera"},"expires":1749900000000}
Connection and serialization concerns
Keyv serializes values to JSON before writing to Redis, so anything you cache must be JSON-safe. This is the most common source of surprises when migrating from the in-memory store, which kept live object references.
| Concern | What to know |
|---|---|
| Serialization | Values are JSON-stringified. Date, Map, Set, BigInt, and class instances lose their type — a Date returns as a string. |
| Functions / circular refs | Cannot be serialized; strip them before caching or cache a plain DTO. |
| TTL units | cache-manager v5 TTLs are in milliseconds, unlike v4 which used seconds. |
| Key prefix | The Keyv namespace is prepended to every key, isolating apps and environments. |
| Connection string | redis://, rediss:// (TLS), and redis://user:pass@host:6379/0 are all accepted by @keyv/redis. |
| Failure mode | A handled error event degrades to misses; wrap reads so a down Redis never breaks the request. |
Gotcha: Because values round-trip through JSON, a cached entity’s
Datefields come back as ISO strings. Re-hydrate them (new Date(value.createdAt)) after a cache hit, or cache a shape that has noDatefields, to avoid subtle type bugs downstream.
Best practices
- Use
registerAsyncwithConfigServiceso the Redis URL and TLS settings come from environment variables, never hardcoded source. - Set a
namespace(key prefix) per application and environment so staging and production can safely share a Redis cluster. - Always attach a
keyv.on('error', ...)handler so a transient Redis outage degrades to cache misses instead of crashing the process. - Remember TTLs are milliseconds in cache-manager v5; auditing old second-based values prevents accidentally caching things for far too long.
- Only cache JSON-serializable data, and re-hydrate
Date/Map/class instances after a hit — do not assume reference identity survives the round trip. - Pair short TTLs with explicit
del()invalidation on writes so every instance sees fresh data immediately after a mutation. - Enable a Redis
maxmemorypolicy such asallkeys-lruso the cache evicts gracefully under pressure rather than refusing writes.