Cache Interceptors
CacheInterceptor caches a whole GET response and short-circuits the handler, which is wrong on any route that varies per caller.
@UseInterceptors(CacheInterceptor) @CacheKey('products') @CacheTTL(5000)An interceptor is a class with an intercept(context, next) method that wraps your route handler. Code before next.handle() runs before the handler; RxJS operators applied to the stream it returns run after. Interceptors sit at a fixed point in the request lifecycle: middleware, then guards, then interceptors on the way in, then pipes, then the handler, then interceptors on the way out, then exception filters if something throws.
What CacheInterceptor does
On the way in it computes a key and calls get. On a hit it returns the stored value as the response and never calls next.handle(), so the pipes, the handler, and any interceptor bound inside it do not run at all. On a miss it lets the chain proceed and writes the response on the way out. It also sets an X-Cache: HIT or MISS header on HTTP responses.
The key
For an HTTP app the key is the request URL, path and query string included, and by default only GET requests are considered. @CacheKey() overrides the key and @CacheTTL() overrides the TTL; both accept a literal or a factory that receives the ExecutionContext. An explicit @CacheKey() on a route handler is also what WebSocket and microservice handlers need, because outside HTTP there is no URL to fall back on.
Where it is wrong
The URL is the entire key, so a response that depends on the caller is served to the next caller. Routes that inject @Res(), streamed files and GraphQL field resolvers are not cached. Non-GET routes are skipped by the default key, but a @CacheKey() on the handler bypasses that check, so a POST carrying one will be cached — which is almost never what you want. For per-user routes, subclass and override trackBy.
Example
// src/products/products.controller.ts
import { Controller, Get, Param, ParseIntPipe, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
import { ProductsService } from './products.service';
Controller('products')
export class ProductsController {
constructor(private readonly products: ProductsService) {}
// A method decorator binds to this handler only, which is why the second
// route repeats it. Put @UseInterceptors on the class to cover both.
UseInterceptors(CacheInterceptor)
Get()
// Fixed key: every caller of GET /products shares this entry.
CacheKey('products:list')
CacheTTL(30_000) // milliseconds
findAll() {
return this.products.findAll();
}
UseInterceptors(CacheInterceptor)
Get(':id')
// No @CacheKey, so the key is the request URL: "/products/42".
// On a hit, ParseIntPipe never runs, because the handler is never called.
CacheTTL(60_000)
findOne(Param('id', ParseIntPipe) id: number) {
return this.products.findOne(id);
}
}When to use it
- Cache a public product listing for thirty seconds without touching the service code.
- Collapse a search endpoint's near-duplicate queries onto one key by normalising it.
- Cache authenticated dashboard GETs safely by adding the user id to the key.
- Confirm from the X-Cache response header whether a route is actually being served from cache.
More examples
Binding it globally
This caches every GET route in the application, keyed by URL. It is only safe if no GET response varies by caller anywhere, which is rare. Prefer binding per controller, or use the subclass below.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [CacheModule.register({ isGlobal: true, ttl: 5_000 })],
providers: [
// APP_INTERCEPTOR registers an interceptor for every route in the app.
// Global interceptors are the outermost layer: they run first on the way in
// and last on the way out, so a cache hit here skips controller-level and
// method-level interceptors too.
{ provide: APP_INTERCEPTOR, useClass: CacheInterceptor },
],
})
export class AppModule {}A per-user key
The key now includes the caller, so one user's body cannot be served to another. Use this for any authenticated GET route worth caching. Returning undefined is the documented way to opt a request out: the interceptor calls the handler and stores nothing.
// src/cache/user-cache.interceptor.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
interface AuthedRequest {
method: string;
originalUrl: string; // Express; use req.url on Fastify
user?: { id: string };
}
@Injectable()
export class UserCacheInterceptor extends CacheInterceptor {
// trackBy returns the cache key, or undefined to skip caching entirely.
protected trackBy(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest<AuthedRequest>();
if (!this.allowedMethods.includes(request.method)) {
return undefined; // allowedMethods is ['GET'] on the base class
}
if (!request.user) {
return undefined; // never cache an anonymous response under a user key
}
return `user:${request.user.id}:${request.originalUrl}`;
}
}Keys and TTLs computed per request
@CacheKey takes a string or (ctx) => string | Promise<string | undefined> | undefined, and @CacheTTL a number or (ctx) => number | Promise<number>. The factories are how you reach request data without writing a whole subclass. The TTL is read from the handler first and then from the controller class; the key is read from the handler only.
// src/catalog/catalog.controller.ts
import { Controller, ExecutionContext, Get, Query, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
@Controller('catalog')
@UseInterceptors(CacheInterceptor)
// A class-level @CacheTTL applies to every handler that does not set its own.
@CacheTTL(10_000)
export class CatalogController {
@Get('search')
// Factory form: receives the ExecutionContext and returns the key.
// Normalising the query here means ?q=Shoes and ?q=shoes share one entry,
// which the default URL key would not.
@CacheKey((ctx: ExecutionContext) => {
const { query } = ctx.switchToHttp().getRequest<{ query: { q?: string } }>();
return `catalog:search:${(query.q ?? '').trim().toLowerCase()}`;
})
// Factory form of the TTL: cache an empty query longer than a specific one.
@CacheTTL((ctx: ExecutionContext) => {
const { query } = ctx.switchToHttp().getRequest<{ query: { q?: string } }>();
return query.q ? 10_000 : 300_000;
})
search(@Query('q') q?: string) {
return { q, results: [] };
}
}
Discussion