Skip to main content

How to implement caching strategies in NestJS?

NestJS caching stores the result of expensive operations in fast memory (RAM or Redis) so repeated requests skip the computation entirely.

Theory

TL;DR

  • Think of cache like a whiteboard where you write down answers to questions you already solved. Next request reads the whiteboard instead of recalculating.
  • Two main stores: in-memory (single instance, zero config) and Redis (shared across pods, survives restarts).
  • CacheInterceptor caches controller GET responses automatically. CACHE_MANAGER gives you manual get/set/del control inside services.
  • Decision rule: one pod or dev environment = in-memory. Multiple pods or over 1GB of data = Redis.
  • Skip caching for real-time data (live prices, chat messages) or endpoints where writes outnumber reads.

Quick setup

Install the packages first:

bash
npm install @nestjs/cache-manager cache-manager npm install cache-manager-redis-yet redis # only if using Redis

In-memory setup in app.module.ts:

typescript
import { CacheModule } from '@nestjs/cache-manager'; @Module({ imports: [ CacheModule.register({ isGlobal: true, // available in all modules without re-importing ttl: 60000, // 60s in milliseconds max: 100, // LRU eviction after 100 items }), ], }) export class AppModule {}

Set isGlobal: true once and every module can inject CACHE_MANAGER without importing CacheModule again.

Auto-caching with CacheInterceptor

CacheInterceptor hooks into NestJS's interceptor chain and caches GET responses without any manual code. The cache key comes from the request URL plus query params, so /products?page=1 and /products?page=2 get separate entries automatically.

typescript
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager'; @Controller('products') export class ProductsController { @Get() @UseInterceptors(CacheInterceptor) // only this GET is cached @CacheTTL(30000) // override module default: 30s for this endpoint findAll() { return this.productsService.findAll(); } @Get(':id') @UseInterceptors(CacheInterceptor) @CacheKey('product-detail') // fixed key - same for ALL :id values! @CacheTTL(60000) findOne(@Param('id') id: string) { return this.productsService.findOne(id); } }

The @CacheKey('product-detail') on findOne is a trap. A fixed string means every product ID returns the same cached result. See Common mistakes for the fix.

Manual cache management with CACHE_MANAGER

When you need control at the service layer, such as caching inside business logic or invalidating on update, inject CACHE_MANAGER directly:

typescript
import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; @Injectable() export class ProductsService { constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} async findOne(id: string) { const key = `product:${id}`; const cached = await this.cache.get(key); if (cached) return cached; // hit: ~1ms const product = await this.db.findById(id); // miss: DB query ~80ms await this.cache.set(key, product, 60000); return product; } async update(id: string, data: UpdateProductDto) { const product = await this.db.update(id, data); await this.cache.del(`product:${id}`); // invalidate right after write return product; } }

This is the cache-aside pattern: read from cache, fall back to source on miss, write the result back.

Redis for multi-instance deployments

In-memory cache disappears with the process and is not shared between pods. Run two NestJS instances behind a load balancer, and each has its own isolated cache. User A hits pod 1 (cache warm), user B hits pod 2 (cache cold), the same DB query fires twice. Redis solves this:

typescript
import { redisStore } from 'cache-manager-redis-yet'; CacheModule.registerAsync({ isGlobal: true, useFactory: async () => ({ store: await redisStore({ socket: { host: process.env.REDIS_HOST || 'localhost', port: 6379, pingInterval: 30000, // keep-alive retryDelay: 100, // retry after connection drop }, ttl: 300000, }), }), })

Without pingInterval and retryDelay, a network blip causes silent 500 errors. The connection drops and the app has no idea until the next process restart.

Comparison: in-memory vs Redis vs Memcached

FeatureIn-MemoryRedisMemcached
SetupCacheModule.register({ttl})redisStore({host, port})cache-manager-memcached
PersistenceNoYes (AOF/RDB)No
DistributedNo (per process)Yes (cluster/sharding)Yes (consistent hashing)
EvictionLRU (max: 100 default)allkeys-lruLRU
When to useLocal dev, single podMicroservices, K8sHigh-throughput, legacy stacks

How CacheModule works internally

CacheModule wraps cache-manager v5+. When you add CacheInterceptor, it registers as APP_INTERCEPTOR in the DI container. On each request, the interceptor computes a key from the URL and query string, calls store.get(key), and short-circuits the handler on a hit. On miss, the handler executes, and the result goes through store.set(key, JSON.stringify(result), ttl). For Redis, ioredis handles the async I/O without blocking the event loop.

One thing I have seen bite teams in production: the default max: 100 for in-memory means the 101st unique key evicts the oldest entry. If your app has thousands of product IDs, most requests will be cache misses even though the cache looks active. Raise max to match your working set size.

Common mistakes

Mistake 1: Fixed key on a dynamic route

typescript
// Wrong - all product IDs share one cache entry @Get(':id') @CacheKey('product-detail') findOne(@Param('id') id: string) { ... }

Every user gets whichever product was cached first. Fix: use dynamic keys in the service through CACHE_MANAGER directly, with keys like `product:${id}`.

Mistake 2: Controller-level interceptor caches mutations

typescript
@Controller('orders') @UseInterceptors(CacheInterceptor) // applies to ALL methods export class OrdersController { @Post() // this mutation now gets cached create(@Body() dto: CreateOrderDto) { ... } }

Apply @UseInterceptors(CacheInterceptor) at the method level on GET handlers only, not at the controller level when mutations are present.

Mistake 3: No serialization guard for complex objects

typescript
await this.cache.set('data', { date: new Date(), count: 9007199254740993n }); // Throws: Do not know how to serialize BigInt

JSON.stringify fails on BigInt, Date objects, and circular references. Serialize manually before storing or use instanceToPlain from class-transformer.

Mistake 4: Redis without health checks

typescript
store: await redisStore({ socket: { host: 'prod.redis.internal' } }) // No pingInterval, no retryDelay - app throws 500 on any network blip

Always add pingInterval: 30000 and retry config. Without them, the connection drops quietly and every cache call throws until the next restart.

Mistake 5: TTL set to 0 or left undefined

In some versions of cache-manager, ttl: 0 means "never expire" rather than "do not cache". Items pile up in memory until the process runs out of RAM. Always set an explicit positive TTL. Use invalidation events for accuracy instead of relying on zero TTL.

Real-world usage

  • E-commerce product lists: cache findMany with 60s TTL, invalidate on inventory update via cacheManager.del.
  • GraphQL resolvers: @CacheKey on resolver methods for federated schemas where the same query runs across multiple subgraphs.
  • Microservices: Redis pub/sub for cross-service invalidation - one service publishes "user.updated", another subscribes and flushes `user:${id}` keys.
  • Pair with @nestjs/throttler: caching cuts DB load, throttler limits request volume. Together they cover most high-traffic scenarios.

Follow-up questions

Q: How does CacheInterceptor generate cache keys?
A: It uses the request URL plus query string, prefixed with nest:. The method and request body are not included by default. Override by extending CacheKeyStrategy.

Q: What is the difference between @CacheKey() and @CacheTTL()?
A: @CacheKey sets the storage identifier and replaces the auto-generated URL-based key. @CacheTTL overrides the TTL from CacheModule.register for that specific method only.

Q: How do you handle cache stampede (thundering herd)?
A: Stampede happens when many requests hit a cold cache simultaneously and all go to the DB. The fix is a mutex: set a temporary lock key via SETNX in Redis before fetching, release it after writing. Requests that find the lock either wait or return stale data.

Q: In K8s with 10 pods, how do you invalidate cache across all of them without Redis pub/sub?
A: Short TTLs plus write-through invalidation on a shared Redis instance that all pods connect to. If you are stuck with in-memory only, sticky sessions keep one user on one pod, but nine pods still serve stale data. The real answer: use Redis in any multi-pod setup.

Q: When does cacheManager.del() silently fail?
A: When the key was stored with a prefix (like nest:users) but del('users') is called without it. Also, delMany in cache-manager before v7 does not support Redis pipelines, so large batch deletes can fail without throwing. Use explicit pipeline calls for atomic multi-key eviction.

Examples

Basic: in-memory cache for a products endpoint

typescript
// app.module.ts import { Module } from '@nestjs/common'; import { CacheModule } from '@nestjs/cache-manager'; @Module({ imports: [ CacheModule.register({ isGlobal: true, ttl: 60000, max: 200, // raise from default 100 if you have many unique keys }), ], }) export class AppModule {} // products.controller.ts import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'; @Controller('products') export class ProductsController { constructor(private readonly productsService: ProductsService) {} @Get() @UseInterceptors(CacheInterceptor) // only this GET is cached @CacheTTL(30000) // 30s for this endpoint findAll() { return this.productsService.findAll(); // First call: DB query ~80ms. Next calls: ~1ms from cache. } }

The interceptor generates the key from /products plus query string. Add ?category=shoes and you get a separate cache entry with no extra code.

Intermediate: Redis cache with manual invalidation on update

Real scenario: user profile in an e-commerce app. Profile data changes rarely but is read on every page load.

typescript
// user-cache.service.ts import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; @Injectable() export class UserCacheService { constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} async getProfile(userId: string) { const key = `user:${userId}`; const cached = await this.cache.get(key); if (cached) return cached; // hit: ~1ms const profile = await this.db.users.findById(userId); // miss: ~80ms await this.cache.set(key, profile, 300000); // 5 minutes return profile; } async invalidate(userId: string) { await this.cache.del(`user:${userId}`); } } // users.controller.ts @Controller('users') export class UsersController { constructor( private readonly userCache: UserCacheService, private readonly usersService: UsersService, ) {} @Get(':id') getProfile(@Param('id') id: string) { return this.userCache.getProfile(id); } @Patch(':id') async update(@Param('id') id: string, @Body() dto: UpdateUserDto) { const updated = await this.usersService.update(id, dto); await this.userCache.invalidate(id); // cache cleared, next GET fetches fresh data return updated; } }

PATCH updates the DB first, then clears the cache. The next GET rebuilds the cache entry from the fresh DB state.

Advanced: reusable cache-aside helper with typed generics

This pattern comes up in any service with multiple cached resources. Instead of repeating get/check/set everywhere, extract it into a helper:

typescript
// cache.helper.ts import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; @Injectable() export class CacheHelper { constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} async getOrSet<T>( key: string, factory: () => Promise<T>, ttl: number, ): Promise<T> { const cached = await this.cache.get<T>(key); if (cached !== null && cached !== undefined) return cached; const value = await factory(); await this.cache.set(key, value, ttl); return value; } async invalidateMany(keys: string[]): Promise<void> { await Promise.all(keys.map((k) => this.cache.del(k))); } } // orders.service.ts @Injectable() export class OrdersService { constructor( private readonly cacheHelper: CacheHelper, private readonly db: PrismaService, ) {} async getOrderSummary(userId: string) { return this.cacheHelper.getOrSet( `orders:summary:${userId}`, () => this.db.order.findMany({ where: { userId }, select: { id: true, total: true } }), 120000, // 2 minutes ); } async cancelOrder(orderId: string, userId: string) { await this.db.order.update({ where: { id: orderId }, data: { status: 'cancelled' } }); await this.cacheHelper.invalidateMany([ `order:${orderId}`, `orders:summary:${userId}`, // related aggregate also cleared ]); } }

invalidateMany uses Promise.all for parallel deletes instead of sequential awaits. On Redis this saves round-trip time when clearing multiple related keys at once.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?