Caching in Redis
Move the cache out of the process so replicas share it, and learn what JSON serialisation does to your values.
CacheModule.registerAsync({ useFactory: () => ({ stores: [createKeyv(url)] }) })The default store lives in the memory of one Node process. Run three replicas and you have three caches with three different answers, and a redeploy empties all of them. Redis holds the entries outside the process, so every replica reads and writes the same data and a restart changes nothing.
Wiring it up
Install @keyv/redis. In @nestjs/cache-manager 12 a store is a Keyv instance: Keyv is the adapter layer that cache-manager 6 and later use for every backend, which is why there is no store: 'redis' string any more. createKeyv(url) returns a Keyv backed by Redis; put it in the stores array.
Values are serialised
Redis stores strings, so Keyv encodes through @keyv/serialize, a JSON encoder with Buffer support. JSON rules follow: a Date returns as a string, a class instance as a plain object, Map and Set do not survive, and undefined properties disappear. Cache plain data and rebuild the objects after reading, or the first value.createdAt.getTime() throws.
Layers
stores accepts several Keyv instances. A small in-process store in front of Redis answers repeat reads without a network hop, while Redis keeps the replicas consistent.
Failures are silent
By default @keyv/redis does not throw on operation errors; it emits error and returns a no-op. A broken Redis therefore looks like a cache that never hits, not like an outage.
Example
// 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';
Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
useFactory: (config: ConfigService) => {
// getOrThrow fails bootstrap if REDIS_URL is missing,
// instead of silently falling back to the in-memory store.
const url = config.getOrThrow<string>('REDIS_URL');
// namespace prefixes every key with "orders::" inside Redis,
// so this service's keys cannot collide with another app's.
const redis = createKeyv(url, {
namespace: 'orders',
connectionTimeout: 5_000, // milliseconds
});
// The adapter reports problems as events; without a listener
// a failing Redis is invisible.
redis.on('error', (error: unknown) => {
console.error('[cache] redis store error', error);
});
return {
stores: [redis],
ttl: 60_000, // default ttl in milliseconds
};
},
inject: [ConfigService],
}),
],
})
export class AppCacheModule {}When to use it
- Give four replicas behind a load balancer one cache instead of four divergent ones.
- Keep the cache warm across deploys, so a rollout does not stampede the database.
- Put a short-lived memory tier in front of Redis for a key read hundreds of times a second.
- Isolate one service's keys from the rest of a shared Redis with a namespace prefix.
More examples
createKeyv, and the longhand it replaces
Use createKeyv unless you need to hand the adapter an existing node-redis client or a cluster configuration. The longhand matters when reading someone else's module, because the extra prefix explains keys in Redis that do not look like the ones in the code.
// src/cache/redis-store.ts
import KeyvRedis, { createKeyv } from '@keyv/redis';
import { Keyv } from 'keyv';
const url = 'redis://localhost:6379';
// Shorthand. Returns a Keyv wrapping the Redis adapter, with Keyv's own
// key prefix switched off, so keys reach Redis exactly as you wrote them.
export const shorthand = createKeyv(url);
// Longhand. The same two objects, assembled by hand. Here Keyv applies its
// own namespace prefix (default "keyv") on top of the adapter's, which is
// the double prefixing createKeyv exists to avoid.
export const longhand = new Keyv({ store: new KeyvRedis(url) });
// Longhand with the prefixing turned off, i.e. equivalent to createKeyv(url).
export const longhandEquivalent = new Keyv(new KeyvRedis(url), {
useKeyPrefix: false,
});Memory in front of Redis
Reads are answered by the first store that has the key; writes and deletes go to both. Reach for this when a key is read many times per second and a five second window of local staleness is acceptable. Note that tier 1 is per replica, so a delete issued on one instance does not clear another instance's memory copy before its own TTL expires.
// src/cache/cache.module.ts (two-tier variant)
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
import { KeyvCacheableMemory } from 'cacheable';
import { Keyv } from 'keyv';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: () => ({
stores: [
// Tier 1: this process only. lruSize caps the entry count
// so the heap cannot grow without limit.
new Keyv({
store: new KeyvCacheableMemory({ ttl: 5_000, lruSize: 5_000 }),
}),
// Tier 2: shared by every replica
createKeyv(process.env.REDIS_URL ?? 'redis://localhost:6379'),
],
ttl: 60_000,
}),
}),
],
})
export class TwoTierCacheModule {}Rehydrating what JSON flattened
Typing the cached shape separately from the domain class makes the loss explicit, so the compiler stops you calling a method that the deserialised value does not have. Do this for anything richer than a string or a number.
// src/orders/orders.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
// What is actually stored: plain JSON-safe fields only.
interface CachedOrder {
id: string;
total: number;
placedAt: string; // an ISO string, never a Date, once it has been through Redis
}
export class Order {
constructor(
readonly id: string,
readonly total: number,
readonly placedAt: Date,
) {}
}
@Injectable()
export class OrdersService {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async get(id: string): Promise<Order | undefined> {
const row = await this.cache.get<CachedOrder>(`order:${id}`);
if (!row) {
return undefined;
}
// Rebuild the class and the Date on the way out.
return new Order(row.id, row.total, new Date(row.placedAt));
}
async put(order: Order): Promise<void> {
await this.cache.set<CachedOrder>(
`order:${order.id}`,
{
id: order.id,
total: order.total,
placedAt: order.placedAt.toISOString(),
},
300_000,
);
}
}
Discussion