Caching Interceptor
Interceptors are unique among NestJS request-pipeline constructs because they can decide whether the route handler runs at all. Since intercept controls when (or if) next.handle() is called, an interceptor can check a cache and, on a hit, return a synthetic Observable of the stored value without ever invoking the handler. This page builds a caching interceptor by hand to understand the mechanics, then shows how the official @nestjs/cache-manager package packages the same idea for production use.
Why interceptors can short-circuit
The signature of intercept(context, next) hands you a CallHandler rather than the handler’s result directly. Nothing forces you to call next.handle(). If you can produce a response some other way — from a cache, a feature flag, or a precomputed value — you simply return an Observable of it. The handler, its services, and any database calls inside it are skipped entirely.
RxJS of(value) is the tool for this. It creates an Observable that immediately emits value and completes, which is exactly what downstream operators (and Nest’s response serializer) expect from next.handle(). This makes a cache hit indistinguishable from a real handler response to the rest of the pipeline.
A hand-rolled caching interceptor
The interceptor below caches GET responses keyed by URL. On a hit it returns of(cached) to short-circuit; on a miss it lets the handler run and uses tap to store the freshly produced value with a TTL.
// src/common/interceptors/caching.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, of, tap } from 'rxjs';
import { Request } from 'express';
interface CacheEntry {
value: unknown;
expiresAt: number;
}
@Injectable()
export class CachingInterceptor implements NestInterceptor {
private readonly store = new Map<string, CacheEntry>();
private readonly ttlMs = 5_000;
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
// Only cache idempotent reads.
if (request.method !== 'GET') {
return next.handle();
}
const key = request.url;
const entry = this.store.get(key);
if (entry && entry.expiresAt > Date.now()) {
// Cache hit: skip the handler completely.
return of(entry.value);
}
// Cache miss: run the handler, then store its result.
return next.handle().pipe(
tap((value) => {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs,
});
}),
);
}
}
The if (entry && entry.expiresAt > Date.now()) branch never touches next, so the wrapped handler is bypassed. Every other path returns the genuine handler stream, with tap recording the value as a side effect without altering it.
Seeing the short-circuit in action
Bind the interceptor to a controller whose handler is deliberately slow, so a cache hit is obvious from the response timing.
// src/products/products.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CachingInterceptor } from '../common/interceptors/caching.interceptor';
@Controller('products')
@UseInterceptors(CachingInterceptor)
export class ProductsController {
@Get('featured')
async getFeatured() {
// Simulate an expensive query.
await new Promise((resolve) => setTimeout(resolve, 800));
return { items: ['camera', 'lens'], generatedAt: Date.now() };
}
}
The first request pays the 800ms cost; the second, within the TTL window, returns instantly with the identical generatedAt value — proof the handler did not re-run.
time curl -s http://localhost:3000/products/featured
time curl -s http://localhost:3000/products/featured
Output:
{"items":["camera","lens"],"generatedAt":1749897662001}
real 0m0.832s
{"items":["camera","lens"],"generatedAt":1749897662001}
real 0m0.009s
Using @nestjs/cache-manager
For real applications, reach for the official package instead of a hand-rolled Map. It ships a CacheInterceptor that auto-caches GET responses and a CacheModule backed by pluggable stores (in-memory, Redis, and more).
npm install @nestjs/cache-manager cache-manager
// src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProductsModule } from './products/products.module';
@Module({
imports: [
CacheModule.register({ ttl: 5_000, max: 100, isGlobal: true }),
ProductsModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
],
})
export class AppModule {}
You can tune per-handler behaviour with decorators. @CacheKey overrides the auto-generated key and @CacheTTL sets a route-specific lifetime.
// src/products/products.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CacheKey, CacheTTL } from '@nestjs/cache-manager';
@Controller('products')
export class ProductsController {
@Get('featured')
@CacheKey('featured-products')
@CacheTTL(10_000)
getFeatured() {
return { items: ['camera', 'lens'] };
}
}
| Concern | Hand-rolled interceptor | @nestjs/cache-manager |
|---|---|---|
| Storage backend | Manual Map | Pluggable (memory, Redis, etc.) |
| Key strategy | Custom logic | URL by default; @CacheKey to override |
| TTL control | Hardcoded field | CacheModule default + @CacheTTL |
| Eviction / size limit | You implement it | max option, LRU eviction |
| Tracking control | Always on | trackBy override per request |
Tip: The built-in
CacheInterceptoronly cachesGETrequests by default. To cache other methods, or to exclude specific routes, subclass it and overrideisRequestCacheable()ortrackBy().
Gotcha: Never cache responses that depend on the authenticated user with a URL-only key — two different users hitting the same path would share a cache entry. Override
trackBy()to fold the user id (or an auth header) into the key for per-user responses.
Best practices
- Restrict caching to idempotent reads (
GET) and explicitly bypass mutating methods so writes always hit the handler. - Use
of(value)to short-circuit so a cache hit looks identical to a real response to downstream operators and serializers. - Prefer
@nestjs/cache-managerover a bespokeMapin production — it gives you TTL, size limits, eviction, and Redis-backed sharing across instances. - Include all request dimensions that affect the response (user, query params, headers) in the cache key to avoid serving one client’s data to another.
- Keep TTLs short for volatile data and add explicit invalidation on writes rather than relying on expiry alone.
- Store the value inside
tapon the miss path so caching is a pure side effect that never mutates the emitted response. - For multi-instance deployments, back the cache with Redis so a value cached on one node is visible to all.