Skip to content

NestJS Custom Interceptors

This lesson builds a complete, practical custom interceptor: a simple in-memory response cache, showing how to combine ExecutionContext, RxJS operators, and conditional logic inside intercept().

A Simple Caching Interceptor

The interceptor checks an in-memory cache before calling the handler. If a cached value exists, it returns it immediately (skipping next.handle() entirely); otherwise, it calls the handler and caches the result for next time.

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, unknown>();

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest();
    const key = request.url;

    if (this.cache.has(key)) {
      return of(this.cache.get(key));
    }

    return next.handle().pipe(
      tap((response) => this.cache.set(key, response)),
    );
  }
}

of(this.cache.get(key)) returns an Observable that immediately emits the cached value, entirely bypassing the actual route handler on a cache hit.

Applying Only to GET Requests

intercept(context: ExecutionContext, next: CallHandler) {
  const request = context.switchToHttp().getRequest();
  if (request.method !== 'GET') {
    return next.handle();
  }
  // caching logic only for GET
}
  • Checking request.method lets an interceptor apply conditional logic based on the HTTP verb.
  • of(value) (from rxjs) wraps a plain value in an Observable when you need to skip next.handle().
  • A real-world cache interceptor would use TTLs and a shared store (like Redis) instead of an in-memory Map.
  • Cache keys typically need to include query parameters and, for per-user data, the authenticated user's id.

Custom Interceptor Building Blocks

Pieces used to build a caching (or similar) interceptor.

Piece Purpose
context.switchToHttp().getRequest() Access the current request for cache key logic
of(value) Emit a value immediately without calling the handler
next.handle().pipe(tap(...)) Run a handler and store its result afterward
CACHE_TTL metadata Optional per-route cache duration via a custom decorator

Cache Invalidation Matters More Than Caching

The hardest part of any caching layer isn't storing values, it's knowing when to invalidate them. A POST, PATCH, or DELETE on the same resource should typically clear the matching cache entries, otherwise clients see stale data indefinitely.

  • Invalidate (or don't cache) mutating requests entirely, only cache safe GET requests.
  • Clear related cache keys whenever the underlying resource changes.
  • Prefer short TTLs over indefinite caching unless invalidation is airtight.

Nest's Built-in CacheInterceptor

NestJS ships its own CacheInterceptor (from @nestjs/cache-manager) with pluggable stores, including Redis, for production use. Building a custom one, as in this lesson, is valuable for learning the mechanics before adopting the official module.

Common Mistakes

  • Caching responses that include per-user data using a cache key that doesn't account for the user.
  • Never invalidating cached entries after a mutating request changes the underlying data.
  • Using an in-memory cache in a multi-instance deployment, where each instance has its own inconsistent cache.
  • Caching error responses, causing a temporary failure to be served repeatedly from cache.

Key Takeaways

  • A custom interceptor can short-circuit next.handle() entirely by returning of(value).
  • tap() is useful for storing a handler's result as a side effect without altering it.
  • Cache invalidation strategy matters more than the caching mechanism itself.
  • NestJS's official CacheInterceptor and @nestjs/cache-manager are the production-ready path for real caching.

Pro Tip

Before building a custom cache interceptor for production use, check whether @nestjs/cache-manager's built-in CacheInterceptor combined with a Redis store already covers your needs, most caching requirements don't need a fully custom implementation.