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.

Table 1. Cache strategies and how they map to NestJS
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 CACHE_MANAGER, or cache.wrap(). This is also what CacheInterceptor does for a whole response.

Read-through

The cache itself knows how to load a missing value.

cache.wrap(key, loader) is the closest equivalent: the cache calls your loader on a miss.

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 cache.set() the new value.

Write-behind (write-back)

Writes go to the cache first and reach the database later, asynchronously.

Not provided by @nestjs/cache-manager; build it with a queue (see Events, scheduling and queues). It risks losing writes if the cache node dies.

TTL expiration

Every entry has a time to live and disappears when it elapses.

The ttl option, the ttl argument of set() and the @CacheTTL() decorator, all in milliseconds.

Explicit invalidation

The code that changes data removes or replaces the affected entries.

cache.del(key) and cache.clear().

Eviction (LRU, LFU)

When the store is full it drops entries by a policy.

The lruSize option of CacheableMemory; for Redis, maxmemory-policy on the server (see Redis: architecture, keyspace and eviction).

Refresh-ahead

Entries close to expiry are refreshed in the background so readers never wait.

The refreshThreshold option, used by cache.wrap().

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.

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.

Registering the module
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.

A fake repository that counts database reads
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);
  }
}
Cache-aside, write-through and invalidation in a service
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() returns undefined on a miss. cache-manager 6 returned null; version 7, which is what npm installs today, returns undefined. The !== undefined && !== null check above is defensive so the code works with either.

  • set(key, value, ttl) takes the TTL in milliseconds. 30_000 is thirty seconds. A TTL of 0 stores the entry without expiry. If you omit the argument, the module’s ttl option applies, and if that is not set either, entries never expire.

  • del(key) removes one entry and clear() removes everything in the cache. mget(), mset() and mdel() do the same for many keys, and ttl(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 concurrent wrap() calls for the same missing key call the loader once, while three concurrent hand-written get/set calls call it three times. That is the cache stampede: many requests missing at the same moment all hit the database. Prefer wrap() 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:

Injecting by class
import { Injectable } from '@nestjs/common';
import { Cache } from '@nestjs/cache-manager';

@Injectable()
export class RecommendationsService {
  constructor(private readonly cache: Cache) {}
}

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.

refreshThreshold with wrap()
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 Date comes back as a string and a class instance as a plain object. The default in-memory store happens to keep the original Date, 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. CacheableMemory clones 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.

Caching one controller with CacheInterceptor, @CacheKey and @CacheTTL
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 GET is cached by default. The POST /books route returns a different body each time and no X-Cache header. 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/2 expires 5000 ms after it is stored (the controller value), /books/featured after 60000 ms (the method value), and /books/stable with @CacheTTL(0) never. If neither decorator is present, the module’s ttl option is used.

  • Routes that inject the native response with @Res() cannot be cached. The interceptor reports MISS every time. Return the value instead, or set headers through @Res({ passthrough: true }).

  • StreamableFile responses 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 CacheModule does 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.

A global CacheInterceptor and the handlers it skips
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.

Keying the cache by user
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}`;
  }
}
Using it
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
A two-layer cache configured with registerAsync and ConfigService
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_URL unset the cache has one store (the CacheableMemory layer) and set/get work. With lruSize: 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 when set() is called without one, so give every layer a TTL.

  • The @keyv/redis branch (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 a Keyv instance. 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:

  1. 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.

  2. Delete on write. After the database write succeeds, cache.del() every key that depends on the changed data, as remove() and rename() do above. Delete after the write, not before, or a concurrent read can re-fill the cache with the old value.

  3. Write-through. Replace the entry with the new value, as rename() does for the entity itself.

  4. Derived keys. A list such as books:list depends 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.

  5. clear() removes the whole cache. It also removes entries that other features stored in the same store, so use a namespace or 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.

Cached WebSocket and microservice handlers
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: two count requests both return 1.

  • 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 the X-Cache header through the HTTP adapter, that throws because there is no HTTP response in those contexts, and its own try/catch swallows 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:

Making CacheInterceptor work in WebSockets and hybrid apps
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().

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.

What changed recently

  • CacheModule, CACHE_MANAGER, CacheInterceptor, @CacheKey and @CacheTTL moved out of @nestjs/common into @nestjs/cache-manager in NestJS 10.

  • NestJS 11 moved to cache-manager 6 and Keyv. The store option and packages such as cache-manager-redis-store were replaced by the stores option with Keyv instances, so cache-manager-redis-store is obsolete.

  • TTLs are in milliseconds since cache-manager 6; before that they were seconds. A TTL of 5 migrated unchanged now means five milliseconds.

  • With cache-manager 7 a miss returns undefined, not null.

  • @nestjs/cache-manager 12 supports NestJS 9 to 12 through peer dependencies (cache-manager >= 6, keyv >= 5), is ESM-only like the other NestJS 12 packages, and exports Cache as a class usable as an injection token. The examples therefore use .js import extensions.

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.