Caching with Redis

CacheModule with a Redis Store

An in-memory cache disappears on restart and is not shared between instances. Production apps use Redis. With @nestjs/cache-manager 3 (NestJS 11), stores are Keyv adapters.

Example

npm i @nestjs/cache-manager cache-manager @keyv/redis

Example output

added 9 packages in 4s

src/app.module.ts

import { CacheModule } from '@nestjs/cache-manager';
import { ConfigService } from '@nestjs/config';
import { createKeyv } from '@keyv/redis';

@Module({
  imports: [
    CacheModule.registerAsync({
      isGlobal: true,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        stores: [createKeyv(config.getOrThrow<string>('REDIS_URL'))],
        ttl: 60_000,                                    // default TTL in milliseconds
      }),
    }),
  ],
})
export class AppModule {}

Cache-aside in a Service

src/products/products.service.ts

@Injectable()
export class ProductsService {
  constructor(
    private readonly prisma: PrismaService,
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
  ) {}

  async findOne(id: number) {
    const key = `product:${id}`;
    const cached = await this.cache.get<Product>(key);
    if (cached) return cached;                                   // HIT
    const product = await this.prisma.product.findUniqueOrThrow({ where: { id } });
    await this.cache.set(key, product, 5 * 60_000);              // MISS: store for 5 minutes
    return product;
  }

  async update(id: number, dto: UpdateProductDto) {
    const product = await this.prisma.product.update({ where: { id }, data: dto });
    await this.cache.del(`product:${id}`);                       // invalidate on write
    return product;
  }
}

Example

Cache-aside with TTL and invalidation, using a fake clock so the output is stable:

let now = 0;
const db = new Map<number, { id: number; price: number }>([[7, { id: 7, price: 1299 }]]);
let dbReads = 0;

class TtlCache<V> {
  private map = new Map<string, { value: V; expires: number }>();
  get(key: string): V | undefined {
    const e = this.map.get(key);
    if (!e || e.expires <= now) { this.map.delete(key); return undefined; }
    return e.value;
  }
  set(key: string, value: V, ttl: number) { this.map.set(key, { value, expires: now + ttl }); }
  del(key: string) { this.map.delete(key); }
}

const cache = new TtlCache<{ id: number; price: number }>();
function findOne(id: number) {
  const hit = cache.get('product:' + id);
  if (hit) return 'HIT  ' + JSON.stringify(hit);
  dbReads++;
  const row = { ...db.get(id)! };
  cache.set('product:' + id, row, 300_000);
  return 'MISS ' + JSON.stringify(row);
}
function update(id: number, price: number) { db.set(id, { id, price }); cache.del('product:' + id); }

console.log('t=0s   ', findOne(7));
now = 60_000;  console.log('t=60s  ', findOne(7));
update(7, 1199); console.log('updated price to 1199');
console.log('t=60s  ', findOne(7));
now = 400_000; console.log('t=400s ', findOne(7), '(TTL expired)');
console.log('database reads:', dbReads);

Output

t=0s    MISS {"id":7,"price":1299}
t=60s   HIT  {"id":7,"price":1299}
updated price to 1199
t=60s   MISS {"id":7,"price":1199}
t=400s  MISS {"id":7,"price":1199} (TTL expired)
database reads: 3