Suggest an editImprove this articleRefine the answer for “How to implement caching strategies in NestJS?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**NestJS caching strategies** use `CacheModule` from `@nestjs/cache-manager` to store results of expensive DB queries or computations in memory or Redis. Use `CacheInterceptor` for automatic GET response caching, or inject `CACHE_MANAGER` for manual control in services. One pod: in-memory. Multiple pods: Redis. Always invalidate cache on writes.Shown above the full answer for quick recall.Answer (EN)Image**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 | Feature | In-Memory | Redis | Memcached | |---------|-----------|-------|-----------| | Setup | `CacheModule.register({ttl})` | `redisStore({host, port})` | `cache-manager-memcached` | | Persistence | No | Yes (AOF/RDB) | No | | Distributed | No (per process) | Yes (cluster/sharding) | Yes (consistent hashing) | | Eviction | LRU (max: 100 default) | allkeys-lru | LRU | | When to use | Local dev, single pod | Microservices, K8s | High-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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.