Caching
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2) with @nestjs/cache-manager 12.0.0, cache-manager 7.2, Keyv 5.6, cacheable 2.5 and @keyv/redis 5.1, TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. The examples use the Nest 12 CLI project layout (ESM, Vitest, supertest) and the in-memory stores, and the HTTP examples behave the same on Express and Fastify. The one exception is the Redis store: the @keyv/redis code type-checks, but this page has not tested it against a real Redis server.
A cache is a fast, temporary copy of data that is expensive to produce: a database row, a rendered response, the result of a call to another service. Reading from the copy instead of recomputing it lowers latency and load, at the price of the copy being able to go stale. In the Bookshelf application that grows across this section, the book catalogue is read far more often than it is written, which makes it the classic candidate. This page covers the strategies, the official @nestjs/cache-manager package, the two ways of using it (calling the cache by hand and letting CacheInterceptor cache responses), how to configure stores and TTLs, how to invalidate, and the traps that catch most teams: stale data, shared mutable objects, per-user data leaking between users and caching that silently does nothing.
Caching strategies
Before choosing an API, choose a strategy. These are the patterns the caching literature (and the reference book, chapters 2 and 17) uses; the table says where each one lives in a Nest application.
| Strategy | How it works | In NestJS |
|---|---|---|
Cache-aside (lazy loading) |
The application reads the cache first; on a miss it loads from the database and stores the result. |
Hand-written with |
Read-through |
The cache itself knows how to load a missing value. |
|
Write-through |
Every write updates the database and the cache together, so reads after a write see fresh data. |
In the service method that writes: save, then |
Write-behind (write-back) |
Writes go to the cache first and reach the database later, asynchronously. |
Not provided by |
TTL expiration |
Every entry has a time to live and disappears when it elapses. |
The |
Explicit invalidation |
The code that changes data removes or replaces the affected entries. |
|
Eviction (LRU, LFU) |
When the store is full it drops entries by a policy. |
The |
Refresh-ahead |
Entries close to expiry are refreshed in the background so readers never wait. |
The |
Cache-aside plus a TTL plus invalidation on writes is the default recipe, and the one the rest of the page builds. The hard part of caching is not storing data but knowing when it is wrong; the Redis reference page Use cases and patterns discusses cache-aside, read-through, write-behind and the cache stampede from the Redis side.
Source: NestJS documentation: Caching.
Installing @nestjs/cache-manager
Caching is an optional package. Install the Nest integration together with cache-manager, the library it wraps:
npm install @nestjs/cache-manager cache-manager
cache-manager stores everything through Keyv, a key-value abstraction with many storage adapters. By default the entries live in the memory of the Node.js process. To use Redis or another store you add that store’s Keyv package (shown later).
The page uses these imports throughout. Note where each name comes from: CacheModule, CACHE_MANAGER, CacheInterceptor, CacheKey, CacheTTL, CacheOptionsFactory and the Cache class all come from @nestjs/cache-manager, not from @nestjs/common.
Using the cache by hand: CACHE_MANAGER
Register the module once. isGlobal: true makes the cache available everywhere without importing CacheModule again, and ttl sets a default time to live.
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { BooksController } from './books.controller.js';
import { BooksRepository } from './books.repository.js';
import { BooksService } from './books.service.js';
@Module({
imports: [CacheModule.register({ ttl: 10_000, isGlobal: true })],
controllers: [BooksController],
providers: [BooksRepository, BooksService],
exports: [BooksService, BooksRepository],
})
export class BooksCachingModule {}
Inject the cache with the CACHE_MANAGER token. The service below is the book repository’s cache-aside logic. It demonstrates every basic operation: get(), set() with a TTL, del() and wrap(). The repository here is a stand-in for a TypeORM repository (see Data access overview and TypeORM) that counts how many times the "database" is hit.
import { Injectable } from '@nestjs/common';
export interface Book {
id: number;
title: string;
createdAt: Date;
}
/** Stands in for a TypeORM repository; `calls` counts "database" round trips. */
@Injectable()
export class BooksRepository {
calls = 0;
private readonly books = new Map<number, Book>([
[1, { id: 1, title: 'Dune', createdAt: new Date('2026-01-01T00:00:00Z') }],
[2, { id: 2, title: 'Emma', createdAt: new Date('2026-02-01T00:00:00Z') }],
]);
async findAll(): Promise<Book[]> {
this.calls++;
return [...this.books.values()];
}
async findOne(id: number): Promise<Book | undefined> {
this.calls++;
return this.books.get(id);
}
async update(id: number, title: string): Promise<Book | undefined> {
const book = this.books.get(id);
if (book) {
book.title = title;
}
return book;
}
async remove(id: number): Promise<void> {
this.books.delete(id);
}
}
import { Inject, Injectable } from '@nestjs/common';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import { Book, BooksRepository } from './books.repository.js';
const BOOK_TTL_MS = 30_000; // cache-manager TTLs are in milliseconds
const bookKey = (id: number) => `book:${id}`;
const LIST_KEY = 'books:list';
@Injectable()
export class BooksService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly books: BooksRepository,
) {}
/** Cache-aside, written by hand. A miss is `undefined` (cache-manager 7). */
async findOne(id: number): Promise<Book | undefined> {
const cached = await this.cache.get<Book>(bookKey(id));
if (cached !== undefined && cached !== null) {
return cached;
}
const book = await this.books.findOne(id);
if (book) {
await this.cache.set(bookKey(id), book, BOOK_TTL_MS);
}
return book;
}
/** The same pattern with cache.wrap(): get, call the loader on a miss, set. */
findOneWrapped(id: number): Promise<Book | undefined> {
return this.cache.wrap(bookKey(id), () => this.books.findOne(id), BOOK_TTL_MS);
}
async findAll(): Promise<Book[]> {
return this.cache.wrap(LIST_KEY, () => this.books.findAll(), BOOK_TTL_MS);
}
/** Write-through for the entity, invalidation for the derived list. */
async rename(id: number, title: string): Promise<Book | undefined> {
const book = await this.books.update(id, title);
if (book) {
await this.cache.set(bookKey(id), book, BOOK_TTL_MS);
}
await this.cache.del(LIST_KEY);
return book;
}
/** Delete: invalidate both keys. */
async remove(id: number): Promise<void> {
await this.books.remove(id);
await Promise.all([this.cache.del(bookKey(id)), this.cache.del(LIST_KEY)]);
}
}
What this code does:
-
get()returnsundefinedon a miss.cache-manager6 returnednull; version 7, which is what npm installs today, returnsundefined. The!== undefined && !== nullcheck above is defensive so the code works with either. -
set(key, value, ttl)takes the TTL in milliseconds.30_000is thirty seconds. A TTL of0stores the entry without expiry. If you omit the argument, the module’sttloption applies, and if that is not set either, entries never expire. -
del(key)removes one entry andclear()removes everything in the cache.mget(),mset()andmdel()do the same for many keys, andttl(key)returns the entry’s expiry as an absolute timestamp in milliseconds since the epoch (not the remaining time). -
wrap(key, loader, ttl)is cache-aside in one call. Three concurrentwrap()calls for the same missing key call the loader once, while three concurrent hand-writtenget/setcalls call it three times. That is the cache stampede: many requests missing at the same moment all hit the database. Preferwrap()for expensive loaders. -
After
rename(), reading the book does not touch the database (write-through) but the next list read does (the list key has been invalidated).
You can also inject the cache by its class, without @Inject(). Cache from @nestjs/cache-manager is both a type and a provider token:
import { Injectable } from '@nestjs/common';
import { Cache } from '@nestjs/cache-manager';
@Injectable()
export class RecommendationsService {
constructor(private readonly cache: Cache) {}
}
Source: NestJS documentation: Caching, "Interacting with the Cache store", and the cache-manager documentation for wrap(), mget() and refreshThreshold.
Stale-while-revalidate with refreshThreshold
cache-manager can refresh an entry in the background when it is close to expiring. Set a ttl and a smaller-than-ttl-remaining refreshThreshold (both milliseconds) and use wrap(): when a read finds less than refreshThreshold of life left, it returns the cached value immediately and reloads in the background. With ttl: 400 and refreshThreshold: 250, a second read after 200 ms still returns the old value 1 while the loader runs again, and a third read returns 2.
CacheModule.register({ ttl: 400, refreshThreshold: 250 });
// later: the loader runs on a miss, and again in the background near expiry
await this.cache.wrap('k', () => this.load());
Source: the cache-manager documentation, "Refresh cache keys in background".
Pitfalls of storing objects
-
Serialization. A shared store such as Redis serializes values as JSON, so a
Datecomes back as a string and a class instance as a plain object. The default in-memory store happens to keep the originalDate, so code that works in development can break once a Redis layer is added. Treat cached values as JSON. The official page makes the same recommendation: only store JSON-serializable values. -
Shared references. The default in-memory store returns the same object it stored. Changing a property on the object returned by
get()changes the cached value for every later reader.CacheableMemoryclones values, so it behaves differently again. Never mutate what you read from a cache; copy it first. -
Key design. Include everything the value depends on in the key:
book:42,books:list:page=2,user:7:recommendations. A key that omits the user or the locale is how data leaks between users.
Caching responses automatically: CacheInterceptor
CacheInterceptor is a regular interceptor. For each request it computes a key, returns the cached value if there is one (the route handler is not called), and otherwise runs the handler and stores its result. It also sets an X-Cache response header to HIT or MISS, which makes it easy to see what is happening.
import { Body, Controller, Get, Param, ParseIntPipe, Patch, Post, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
import { BooksRepository } from './books.repository.js';
import { BooksService } from './books.service.js';
@Controller('books')
@UseInterceptors(CacheInterceptor) // caches every GET in this controller
@CacheTTL(5_000) // controller-level TTL, milliseconds
export class BooksController {
constructor(
private readonly books: BooksService,
private readonly repo: BooksRepository,
) {}
@Get()
list() {
return this.books.findAll();
}
@Get('featured')
@CacheKey('books:featured') // fixed key instead of the URL
@CacheTTL(60_000) // method-level TTL wins over the controller's
featured() {
return this.repo.findOne(1);
}
@Get('stable')
@CacheTTL(0) // 0 stores the entry without expiry (docs: "never expires")
stable() {
return { at: Date.now() };
}
@Get(':id')
one(@Param('id', ParseIntPipe) id: number) {
return this.repo.findOne(id);
}
@Post()
create(@Body() body: { title: string }) {
return { created: body.title, at: Date.now() }; // POST is never cached by default
}
@Patch(':id')
rename(@Param('id', ParseIntPipe) id: number, @Body() body: { title: string }) {
return this.books.rename(id, body.title);
}
}
The default key is the request URL including the query string, so /books/1?x=1 and /books/1?x=2 are different entries. The responses carry these headers:
curl -si localhost:3000/books | grep -i x-cache
curl -si localhost:3000/books | grep -i x-cache
curl -si "localhost:3000/books/1?x=1" | grep -i x-cache # MISS
curl -si "localhost:3000/books/1?x=2" | grep -i x-cache # MISS: another URL, another key
curl -si "localhost:3000/books/1?x=1" | grep -i x-cache # HIT
X-Cache: MISS
X-Cache: HIT
Rules of the interceptor:
-
Only
GETis cached by default. ThePOST /booksroute returns a different body each time and noX-Cacheheader. Handlers with an explicit@CacheKey()bypass this check (see the global example below). -
@CacheKey('books:featured')replaces the URL with a fixed key. It applies to a handler, not to a controller class. -
@CacheTTL(ms)sets the TTL for that entry. On a controller it applies to every route of the class, and a method-level value wins. In the example,/books/2expires 5000 ms after it is stored (the controller value),/books/featuredafter 60000 ms (the method value), and/books/stablewith@CacheTTL(0)never. If neither decorator is present, the module’sttloption is used. -
Routes that inject the native response with
@Res()cannot be cached. The interceptor reportsMISSevery time. Return the value instead, or set headers through@Res({ passthrough: true }). -
StreamableFileresponses are never stored. -
Errors in the cache never fail a request. If the store throws, the interceptor falls through to the handler.
-
GraphQL is not supported. Interceptors run once per field resolver there, so the official docs warn that
CacheModuledoes not work properly in GraphQL applications.
Binding the interceptor globally
To cache every GET endpoint, register the interceptor as APP_INTERCEPTOR. This is the interceptor-as-provider form described in Interceptors, and it takes part in the request lifecycle exactly like any other global interceptor (see Request lifecycle): after middleware, guards and before pipes, with its post-handler part running on the way out.
import { Controller, Get, Module, Post, Res } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { CacheInterceptor, CacheKey, CacheModule } from '@nestjs/cache-manager';
let hits = 0;
@Controller('stats')
export class StatsController {
@Get()
read() {
return { hits: ++hits };
}
@Post()
write() {
return { hits: ++hits };
}
@Post('report')
@CacheKey('stats:report') // an explicit key opts a non-GET handler in
report() {
return { hits: ++hits };
}
@Get('raw')
raw(@Res() res: { json(body: unknown): void }) {
res.json({ hits: ++hits }); // @Res() handlers cannot be cached
}
}
@Module({
imports: [CacheModule.register({ ttl: 10_000 })],
controllers: [StatsController],
providers: [{ provide: APP_INTERCEPTOR, useClass: CacheInterceptor }],
})
export class GlobalCacheModule {}
Two GET /stats calls return hits: 1 (MISS) and then the same hits: 1 (HIT); two POST /stats calls then return 2 and 3 with no cache header; two POST /stats/report calls return 4 and 4 (MISS, HIT) because @CacheKey() opted that handler in; two GET /stats/raw calls return 5 and 6, never cached.
A global cache is convenient and dangerous in equal measure: every GET you add later is cached, including those that return per-user data. Use it only when every read endpoint is public and tolerates staleness, otherwise bind the interceptor per controller.
Adjusting the key with trackBy
trackBy() is the method the interceptor calls to compute the key. Subclass CacheInterceptor and override it to key by something other than the URL, for example the user. Returning undefined tells the interceptor not to cache that request. The base trackBy() is protected and returns the URL for cacheable GET requests, so super.trackBy() is a convenient starting point.
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
/**
* Keys the cache by user as well as URL, so /me answers are never shared.
* Returning undefined from trackBy() means "do not cache this request".
*/
@Injectable()
export class PerUserCacheInterceptor extends CacheInterceptor {
trackBy(context: ExecutionContext): string | undefined {
const baseKey = super.trackBy(context); // the URL for cacheable GETs
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined> }>();
const user = request.headers['x-user-id'];
if (!baseKey || !user) {
return undefined;
}
return `${user}:${baseKey}`;
}
}
import { Controller, Get, Headers, UseInterceptors } from '@nestjs/common';
import { CacheTTL } from '@nestjs/cache-manager';
import { PerUserCacheInterceptor } from './per-user-cache.interceptor.js';
let renders = 0;
@Controller('me')
@UseInterceptors(PerUserCacheInterceptor)
export class ProfileController {
@Get()
@CacheTTL(10_000)
profile(@Headers('x-user-id') user: string | undefined) {
renders++;
return { user: user ?? 'anonymous', renders };
}
}
With x-user-id set to ana, ana and bob, then twice without a header: the first two requests return renders: 1 (the second a HIT), bob gets his own entry (renders: 2), and the anonymous requests return 3 and 4, so they are not cached. Do not trust a plain header such as x-user-id in production; derive the key from the authenticated user that your guard put on the request (see Guards). The header is used here only to keep the example self-contained.
Configuring the module
TTL and global registration
CacheModule.register() accepts ttl (default time to live in milliseconds, unset means no expiry), isGlobal, namespace (a prefix for every key in the stores, so several applications can share a Redis database), refreshThreshold, nonBlocking (do not wait for the slower stores when several are configured) and stores. The register() shown earlier uses ttl and isGlobal.
Stores: CacheableMemory and Redis
Version 6 of cache-manager replaced the old store packages with Keyv adapters, and NestJS 11 followed. The stores option takes an array of Keyv instances. The first is the primary store; the following ones are fallbacks that are read when the first misses and written along with it. The recommended in-process store is CacheableMemory (from the cacheable package), a bounded LRU map with TTL support; the recommended shared store is Redis through @keyv/redis.
npm install cacheable keyv @keyv/redis
import { Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule, CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
import { Keyv } from 'keyv';
import { KeyvCacheableMemory } from 'cacheable';
/** 1. Factory with injected ConfigService: two layers, the Redis one optional. */
export const cacheFromConfig = CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const stores: Keyv[] = [
// L1: in-process, bounded
new Keyv({ store: new KeyvCacheableMemory({ ttl: config.get<number>('cache.ttl') ?? 60_000, lruSize: 5000 }) }),
];
const redisUrl = config.get<string>('cache.redisUrl');
if (redisUrl) {
// L2: shared between instances. The URL comes from configuration, never from source code.
stores.push(createKeyv(redisUrl, { namespace: 'bookshelf' }));
}
return { stores };
},
});
/** 2. useClass: the options come from a class implementing CacheOptionsFactory. */
@Injectable()
export class CacheConfigService implements CacheOptionsFactory {
createCacheOptions(): CacheModuleOptions {
return { ttl: 5_000, namespace: 'bookshelf' };
}
}
@Module({ providers: [CacheConfigService], exports: [CacheConfigService] })
export class CacheConfigModule {}
export const cacheFromClass = CacheModule.registerAsync({ useClass: CacheConfigService });
/** 3. useExisting: reuse the factory provider another module already created. */
export const cacheFromExisting = CacheModule.registerAsync({
imports: [CacheConfigModule],
useExisting: CacheConfigService,
});
The configuration that feeds it lives in ConfigModule; the Redis URL comes from the environment, never from source code:
ConfigModule.forRoot({
load: [() => ({ cache: { ttl: 60_000, redisUrl: process.env.REDIS_URL } })],
}),
How the layers behave:
-
With
REDIS_URLunset the cache has one store (theCacheableMemorylayer) andset/getwork. WithlruSize: 2, writing a third key evicts the oldest ones from the in-process layer while an unbounded second layer keeps them, and entries in the first layer expire after the configured TTL while a second layer without a TTL still holds them. Each store applies its own TTL whenset()is called without one, so give every layer a TTL. -
The
@keyv/redisbranch (createKeyv(redisUrl, { namespace: 'bookshelf' })) type-checks, but this page has not tested it against a real Redis server.createKeyv()takes a connection string (or client options) and returns aKeyvinstance. Check connection options such as TLS, ACLs and reconnection against the Redis reference: Clients and client-side caching, Redis security and Architecture, keyspace and eviction. -
Redis serializes to JSON, so apply the pitfalls above.
registerAsync: factory, class and existing provider
registerAsync() is the asynchronous counterpart of register() and follows the same pattern as every dynamic module (see Dynamic modules). The example above uses useFactory with inject. The other two forms are useClass, where CacheModule instantiates a class that implements CacheOptionsFactory, and useExisting, where it reuses a provider that another module already created and exports. extraProviders merges additional providers into the module, for the factory or class to depend on. All three forms produce a working cache.
Invalidation
Knowing when a cached value is wrong is the hard part. The tools, from simplest to most precise:
-
TTL only. Accept staleness up to the TTL. Fine for rankings, counters and catalogues. Add some jitter (a random amount) to TTLs of many entries created together so they do not all expire in the same second.
-
Delete on write. After the database write succeeds,
cache.del()every key that depends on the changed data, asremove()andrename()do above. Delete after the write, not before, or a concurrent read can re-fill the cache with the old value. -
Write-through. Replace the entry with the new value, as
rename()does for the entity itself. -
Derived keys. A list such as
books:listdepends on every book. Either delete it on every write or give list keys a version or tag you can change (books:list:v7), because Keyv does not offer a "delete by prefix" operation. -
clear()removes the whole cache. It also removes entries that other features stored in the same store, so use anamespaceor a separate registered cache if that matters, and never call it on a hot path with a shared Redis.
With CacheInterceptor the key is the URL, so a write to /books/1 does not know to invalidate /books?page=3. Either use @CacheKey() for the routes you will invalidate (so the service can del() a known key), keep their TTL short, or use the by-hand cache-aside shown earlier for data that changes. In a multi-instance deployment an in-process layer cannot be invalidated across instances: the other instances keep their copy until the TTL expires, so short in-process TTLs plus a shared Redis layer is the usual shape.
Caching in WebSockets and microservices
CacheInterceptor can wrap WebSocket message handlers and microservice message and event handlers, whatever the transport. There is no URL in those contexts, so @CacheKey() is required: without it nothing is cached. @CacheTTL() works as in HTTP, but has no effect without a key.
import { Controller, Module, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheModule, CacheTTL } from '@nestjs/cache-manager';
import { MessagePattern } from '@nestjs/microservices';
import { TransportCacheInterceptor } from './transport-cache.interceptor.js';
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
let gatewayCalls = 0;
let rpcCalls = 0;
@WebSocketGateway()
export class BooksGateway {
@CacheKey('events') // required: there is no URL to derive a key from
@CacheTTL(5_000)
@UseInterceptors(CacheInterceptor)
@SubscribeMessage('events')
handleEvent(): string[] {
return [`call-${++gatewayCalls}`];
}
@CacheKey('events-fixed')
@CacheTTL(5_000)
@UseInterceptors(TransportCacheInterceptor)
@SubscribeMessage('events-fixed')
handleFixed(): string[] {
return [`call-${++gatewayCalls}`];
}
}
@Controller()
export class CatalogController {
@CacheKey('catalog:count')
@CacheTTL(5_000)
@UseInterceptors(CacheInterceptor)
@MessagePattern({ cmd: 'count' })
count(): number {
return ++rpcCalls;
}
@CacheKey('catalog:count-fixed')
@CacheTTL(5_000)
@UseInterceptors(TransportCacheInterceptor)
@MessagePattern({ cmd: 'count-fixed' })
countFixed(): number {
return ++rpcCalls;
}
}
@Module({ imports: [CacheModule.register()], providers: [BooksGateway] })
export class GatewayModule {}
@Module({ imports: [CacheModule.register()], controllers: [CatalogController] })
export class CatalogModule {}
The behaviour differs from what the official example suggests, and the official page does not mention it:
-
In a standalone microservice (a TCP microservice created with
createNestMicroservice()), the handler decorated like the official example is cached: twocountrequests both return1. -
In a WebSocket gateway running in an HTTP application, and in a hybrid application (HTTP plus a connected microservice), the same decorators do not cache: the handler runs every time (
call-1,call-2). The interceptor tries to set theX-Cacheheader through the HTTP adapter, that throws because there is no HTTP response in those contexts, and its owntry/catchswallows the error and falls through to the handler. Caching then silently does nothing.
The fix is a small subclass that skips the header outside HTTP. setHeadersWhenHttp() is protected precisely so it can be overridden:
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
/**
* CacheInterceptor tries to set an X-Cache header through the HTTP adapter. In a
* WebSocket or RPC handler there is no HTTP response, the call throws, the interceptor
* swallows it and silently skips caching. Skipping the header outside HTTP fixes that.
*/
@Injectable()
export class TransportCacheInterceptor extends CacheInterceptor {
protected override setHeadersWhenHttp(context: ExecutionContext, value: unknown): void {
if (context.getType() === 'http') {
super.setHeadersWhenHttp(context, value);
}
}
}
Using TransportCacheInterceptor on the events-fixed message and the count-fixed pattern in the example above, two WebSocket calls both return call-3, and two RPC calls return the same value in both the standalone and the hybrid application. If you rely on caching in these contexts, add a test that calls the handler twice, because a cache that quietly does nothing looks exactly like a cache that works. Remember also the official advice: never cache handlers that perform a business operation rather than only querying data.
Where caching sits in the request lifecycle
A global, controller or route CacheInterceptor runs in the interceptor stage: after middleware and guards, before pipes and the handler. This has consequences. An unauthorized request is rejected by a guard before the cache is consulted, so a cached response is never served to a caller that fails authorization, as long as the guard is global or on the controller. Because pipes run after the interceptor, a cache hit skips validation of that request, which is harmless for the same URL but another reason to keep the key complete. And exception filters never see a hit, since the handler does not run. For the full order see Request lifecycle, and for the ExecutionContext the interceptor and trackBy() use see Execution context, Reflector and discovery.
Express and Fastify
CacheInterceptor uses the HTTP adapter abstraction (getRequestMethod(), getRequestUrl() and setHeader()), so nothing in this page changes between Express 5 and Fastify 5. On FastifyAdapter, tested with Fastify’s inject(), the controller, per-user interceptor and global module above behave the same: /books returns MISS then HIT, the per-user route returns HIT for the repeated user and no header for the anonymous request, and the global interceptor caches GET /stats the same way. In Fastify tests, call await app.getHttpAdapter().getInstance().ready() before inject().
Source: NestJS documentation: HTTP adapter and Testing.
Testing cached code
Use the in-memory store in tests and inject the cache to inspect it: app.get<Cache>(CACHE_MANAGER) returns the same instance the services use, and cache.clear() in beforeEach keeps tests independent. Assert on behaviour you can observe, such as the X-Cache header with supertest, or the call count of a stubbed repository. Close the application so connections to real stores are released: the cache provider defines an onModuleDestroy hook that disconnects its stores.
Source: NestJS documentation: Testing.
What changed recently
-
CacheModule,CACHE_MANAGER,CacheInterceptor,@CacheKeyand@CacheTTLmoved out of@nestjs/commoninto@nestjs/cache-managerin NestJS 10. -
NestJS 11 moved to
cache-manager6 and Keyv. Thestoreoption and packages such ascache-manager-redis-storewere replaced by thestoresoption with Keyv instances, socache-manager-redis-storeis obsolete. -
TTLs are in milliseconds since
cache-manager6; before that they were seconds. A TTL of5migrated unchanged now means five milliseconds. -
With
cache-manager7 a miss returnsundefined, notnull. -
@nestjs/cache-manager12 supports NestJS 9 to 12 through peer dependencies (cache-manager>= 6,keyv>= 5), is ESM-only like the other NestJS 12 packages, and exportsCacheas a class usable as an injection token. The examples therefore use.jsimport extensions.
Source: NestJS documentation: Migration guide and Caching.
Where the book is outdated
The reference book (see the section bibliography) was published in January 2025 and shows the NestJS 10 era API: it imports CacheModule, CACHE_MANAGER, CacheInterceptor, @CacheKey and @CacheTTL from @nestjs/common, but they live in @nestjs/cache-manager; it configures Redis with cache-manager-redis-store, which is obsolete, whereas today you pass Keyv stores built with @keyv/redis; and it expresses TTLs in seconds, whereas they are now milliseconds. Its strategy discussion (cache-aside, write-through, TTLs, invalidation and eviction) is still valid and is what the first section of this page summarizes.