Interceptors

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), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and is current as of 2026-10-02. The examples use RxJS 7.8 and a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest); the HTTP responses shown are the ones that setup produces.

An interceptor is a class decorated with @Injectable() that implements NestInterceptor. It wraps the execution of a route handler: it can run code before the handler, run code after it by operating on the stream the handler produces, replace the result, replace the error, or skip the handler altogether. Interceptors are Nest’s tool for cross-cutting behaviour such as logging, timing, response shaping, caching and timeouts. In guards you decide whether a handler runs; in an interceptor you decide what happens around it and to its result.

Interceptors and aspect-oriented programming

Nest describes interceptors as inspired by aspect-oriented programming (AOP): you attach extra behaviour (an aspect) to many handlers without editing them. The official documentation lists five capabilities:

  • bind extra logic before or after method execution;

  • transform the result returned from a function;

  • transform the exception thrown from a function;

  • extend the basic function behaviour;

  • completely override a function depending on specific conditions, for example for caching.

In AOP vocabulary the invocation of the route handler (next.handle()) is the pointcut, the point at which the extra logic is woven in. Interceptors differ from the other request enhancers in one respect: because they receive the handler’s result as an RxJS Observable, they see both the request side and the response side of a single call. The other enhancers (middleware, guards, pipes and exception filters) each act at one moment of the request.

NestInterceptor, CallHandler and ExecutionContext

An interceptor implements one method, intercept(context, next), with two arguments:

  • context is an ExecutionContext, the same object guards receive. It extends ArgumentsHost and adds getClass() and getHandler(), which tell you which controller class and method are about to run. Use context.switchToHttp().getRequest() for the HTTP request; the same interceptor can also run in WebSockets, microservice and GraphQL contexts, where you use switchToWs(), switchToRpc() or GqlExecutionContext.

  • next is a CallHandler, an interface with one method, handle(), which invokes the route handler (and any interceptors bound further inside) and returns an Observable of its result.

If you never call handle(), the route handler does not run. Nothing starts until Nest subscribes to the stream you return from intercept(); code placed before next.handle() runs on the way in, and operators applied to the returned observable run on the way out.

NestInterceptor<T, R> is generic: T is the type emitted by the stream next.handle() returns and R is the type emitted by the stream you return. intercept() may be synchronous or async (return a Promise<Observable<R>>) if you need to await something before the handler runs. Interceptors, like other providers, can inject dependencies through their constructor.

The shape of every interceptor
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class PassThroughInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    // 1. code here runs BEFORE the handler
    return next.handle(); // 2. the pointcut: the handler runs when Nest subscribes
    // 3. pipe RxJS operators onto next.handle() to run code AFTER the handler
  }
}

The following diagram shows how three interceptors nest around one handler. The request goes in through each intercept() call in order; the response comes back out through the RxJS operators in the reverse order, because each interceptor’s observable wraps the one inside it.

sequenceDiagram participant C as Client participant G as Global interceptor participant K as Controller interceptor participant M as Method interceptor participant H as Route handler C->>G: request G->>K: next.handle() K->>M: next.handle() M->>H: next.handle() H-->>M: value (Observable emits) M-->>K: mapped by operators K-->>G: mapped by operators G-->>C: response

Logging and timing

The first use case is observing every call. This interceptor logs the matched controller and handler, and logs again with the elapsed time when the response is produced. It uses the tap() operator, which runs a side effect when the stream emits without altering the value, and passes an observer object so the failure path is logged too.

logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const { method, url } = context.switchToHttp().getRequest<{ method: string; url: string }>();
    const handler = `${context.getClass().name}.${context.getHandler().name}`;
    const start = performance.now();
    this.logger.log(`--> ${method} ${url} (${handler})`);

    return next.handle().pipe(
      tap({
        next: () => this.logger.log(`<-- ${method} ${url} ${(performance.now() - start).toFixed(1)}ms`),
        error: (err: Error) =>
          this.logger.warn(`<-- ${method} ${url} failed after ${(performance.now() - start).toFixed(1)}ms: ${err.message}`),
      }),
    );
  }
}

Plain tap(() ⇒ …​) runs only when the handler emits a value; it is not called when the handler throws. To run logic in the failure case use an observer with an error callback, as above, or the finalize() operator. A minimal timer like this is what an APM does automatically; the official documentation points to NestJS Observe for measuring every controller, provider and queue consumer without writing an interceptor.

Binding interceptors and APP_INTERCEPTOR

Like pipes and guards, interceptors can be method-scoped, controller-scoped or global.

Method and controller scope with @UseInterceptors()
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor.js';

@Controller('books')
@UseInterceptors(LoggingInterceptor) // every handler in this controller
export class BooksController {
  @Get('twice')
  @UseInterceptors(LoggingInterceptor) // this handler only (see the pitfalls below)
  twice() {
    return { ok: true };
  }
}

Pass the class, not an instance, so Nest instantiates it and injects its dependencies. Passing an instance (@UseInterceptors(new LoggingInterceptor())) also works, but then you build the object yourself and it cannot receive injected providers.

A global interceptor can be registered in two ways:

Global registration outside any module (no dependency injection)
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());

useGlobalInterceptors() runs outside the context of any module, so that interceptor cannot inject dependencies. To get dependency injection, register the interceptor as a provider with the APP_INTERCEPTOR token from any module:

Global registration with dependency injection
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { BooksController } from './books.controller.js';
import { SimpleCacheInterceptor } from './cache.interceptor.js';
import { EnvelopeInterceptor } from './envelope.interceptor.js';
import { UpstreamErrorsInterceptor } from './errors.interceptor.js';
import { LoggingInterceptor } from './logging.interceptor.js';

@Module({
  controllers: [BooksController],
  providers: [
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_INTERCEPTOR, useClass: EnvelopeInterceptor },
    { provide: APP_INTERCEPTOR, useClass: SimpleCacheInterceptor },
    { provide: APP_INTERCEPTOR, useClass: UpstreamErrorsInterceptor },
  ],
})
export class BooksModule {}

The interceptor is global regardless of which module declares the provider; the official recommendation is to register it in the module where the interceptor class is defined. useClass is not the only form: useFactory and useExisting also work, see Providers & dependency injection and Modules. You can register APP_INTERCEPTOR several times, as above; each registration adds another global interceptor.

Order of execution

Inbound, interceptors run global first, then controller, then method. The response side is resolved in the opposite order (first in, last out), because each stream wraps the next. Three tracer interceptors, one at each scope, recorded this sequence for a GET /order request:

global before > controller before > method before > handler > method after > controller after > global after

Several global interceptors registered with APP_INTERCEPTOR run in registration order on the way in and in reverse on the way out, so where order matters (for example the cache sitting inside the envelope, below) keep the registrations together in one module and say so in a comment. The full ordering of all enhancer types is on Request lifecycle.

Response mapping

The stream returned by handle() carries the value returned by the route handler, so the RxJS map() operator transforms the response. A common use is a uniform response envelope, which is what the Bookshelf API wraps every successful response in:

envelope.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Envelope<T> {
  data: T;
}

@Injectable()
export class EnvelopeInterceptor<T> implements NestInterceptor<T, Envelope<T>> {
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Envelope<T>> {
    return next.handle().pipe(map((data) => ({ data })));
  }
}

With this interceptor registered globally, GET /books (whose handler returns an array) responds as follows.

curl -s localhost:3000/books
{"data":[{"id":1,"title":"Dune"}]}

The same technique handles other global rewrites. For instance, map((value) ⇒ value === null ? '' : value) replaces every null response with an empty string, and one global registration applies it to every handler. For serialising entities (hiding fields, exposing getters) use the built-in ClassSerializerInterceptor rather than a hand-written map().

Response mapping does not work with the library-specific response strategy: if a handler injects the response object with @Res() and sends the response itself, the interceptor’s mapped value is never written. Enable the passthrough option (@Res({ passthrough: true })) so Nest still sends what the handler returns; see Controllers & routing.

Exception mapping

The same stream carries errors. Anything thrown by the handler, by the pipes that run before it, or by services it calls reaches the catchError() operator, so an interceptor can translate one exception into another. This interceptor turns a domain-level UpstreamError into a 502 Bad Gateway and leaves exceptions that are already HTTP exceptions untouched.

errors.interceptor.ts
import {
  BadGatewayException,
  CallHandler,
  ExecutionContext,
  HttpException,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

export class UpstreamError extends Error {}

@Injectable()
export class UpstreamErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      catchError((err: unknown) => {
        if (err instanceof HttpException) {
          return throwError(() => err); // already an HTTP error: leave it alone
        }
        if (err instanceof UpstreamError) {
          return throwError(() => new BadGatewayException('Upstream failure', { cause: err }));
        }
        return throwError(() => err);
      }),
    );
  }
}
curl -s -i localhost:3000/books/upstream
{"message":"Upstream failure","error":"Bad Gateway","statusCode":502}

The original error goes in the cause option, so it is available to logging and filters but is not serialised into the response body. Interceptors only see errors thrown after they are entered: an exception thrown by a guard, which runs earlier in the lifecycle, never reaches catchError(), and neither does anything thrown by middleware. Exceptions that nobody maps are handled by exception filters, which run after the interceptors' response side. Prefer a filter when you want one rule for how a given exception type is rendered everywhere; use an interceptor when the mapping depends on the call (for example, which handler or which upstream was involved).

Stream overriding: a cache interceptor

An interceptor may return a stream that never calls the handler. Returning of(value) from RxJS answers immediately, and the route handler is not invoked. A cache is the classic case. The interceptor below caches GET responses per URL, but only for handlers marked with a @CacheFor(ms) decorator. The decorator is created with Reflector.createDecorator(), and the interceptor reads the setting through the injected Reflector, exactly as a role guard does. The map is a deliberately simple in-memory store; a realistic cache also handles invalidation and size limits.

cache.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';

/** Marks a handler as cacheable for the given number of milliseconds. */
export const CacheFor = Reflector.createDecorator<number>();

@Injectable()
export class SimpleCacheInterceptor implements NestInterceptor {
  private readonly store = new Map<string, { value: unknown; expires: number }>();

  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const ttl = this.reflector.get(CacheFor, context.getHandler());
    if (ttl === undefined) {
      return next.handle(); // not cacheable: pass through
    }
    const { method, url } = context.switchToHttp().getRequest<{ method: string; url: string }>();
    if (method !== 'GET') {
      return next.handle();
    }
    const hit = this.store.get(url);
    if (hit && hit.expires > Date.now()) {
      return of(hit.value); // the handler is never called
    }
    return next.handle().pipe(
      tap((value) => this.store.set(url, { value, expires: Date.now() + ttl })),
    );
  }
}
Marking a handler cacheable
@Get()
@CacheFor(10_000)
findAll() {
  calls.list++; // counts real handler executions
  return [{ id: 1, title: 'Dune' }];
}

Calling GET /books twice returned the same body both times while the handler counter stayed at 1, which shows that the second call never reached the handler. The envelope interceptor registered before the cache wraps the cached value too, so the cached response is still {"data":[…​]}; the cache stores the raw handler result because it sits inside the envelope. This interceptor is a singleton, so its Map is shared by all requests: keep any state you add to an interceptor safe for concurrent use. For production caching use the built-in CacheInterceptor from @nestjs/cache-manager, with @CacheKey() and @CacheTTL(), rather than writing your own.

Source: NestJS documentation: Interceptors, "Stream overriding", Guards for the Reflector.createDecorator() pattern, and Caching.

Timeouts with RxJS

Because the result is an observable, any RxJS operator applies. The timeout() operator fails the stream with a TimeoutError if the handler does not emit within the given time; the interceptor maps that to a RequestTimeoutException (408) and re-throws every other error unchanged.

timeout.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  RequestTimeoutException,
} from '@nestjs/common';
import { Observable, TimeoutError, throwError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

export const REQUEST_TIMEOUT_MS = 100;

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(REQUEST_TIMEOUT_MS),
      catchError((err: unknown) =>
        err instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException())
          : throwError(() => err),
      ),
    );
  }
}

The slow handler sleeps for 500 ms and is bound with @UseInterceptors(TimeoutInterceptor); the example uses a 100 ms limit so the test is fast, where a production value would be in seconds.

curl -s -i localhost:3000/books/slow
{"message":"Request Timeout","statusCode":408}

Timing out the stream does not abort the work the handler has already started: a pending database query or outbound request keeps running after the client gets its 408. If you need to release resources, add that logic (for example an AbortSignal passed into the handler’s calls) before throwing the exception. The RxJS operators used here are introduced in RxJS and asynchronous patterns in the Angular section; it is the same library.

Pitfalls

Registering the same interceptor twice wraps twice

An interceptor bound at several scopes runs once for each binding. If EnvelopeInterceptor is already global through APP_INTERCEPTOR and a controller or method also lists it in @UseInterceptors(), the response is enveloped twice. The following handler repeats the global registration at method level:

Double registration
@Get('doubled')
@UseInterceptors(EnvelopeInterceptor) // already registered globally in BooksModule
doubled() {
  return { ok: true };
}
{"data":{"data":{"ok":true}}}

Register a cross-cutting interceptor once, at the widest scope that is correct, and use a metadata decorator checked through Reflector if some handlers must opt out. The same applies to logging (every request logged twice) and to timeouts.

Other things to remember

  • Forgetting handle(). If an intercept() branch neither calls next.handle() nor returns another stream, the handler is skipped and the request may never complete.

  • Side effects around the handler. Code before next.handle() runs even if the handler later fails, and tap(() ⇒ …​) after it runs only on success. Use finalize() or an error callback for work that must always run.

  • Streams with many emissions. An operator such as map() runs once per emitted value. For handlers returning an Observable that emits repeatedly (for instance server-sent events), an envelope or cache interceptor sees each emission, not one final result.

  • @Res() without passthrough. Interceptors that map the response are bypassed when the handler writes to the platform response itself.

  • Typing the request. switchToHttp().getRequest() returns the Express request or the Fastify request depending on the adapter, so type it narrowly (as the examples do) or branch on the adapter in code meant to run on both.

  • Other transports. Interceptors also exist for WebSocket gateways and microservice handlers with the same NestInterceptor interface, but the switchTo…​() helpers return different objects there.

Express and Fastify

NestInterceptor and CallHandler do not depend on the HTTP platform: an interceptor written against ExecutionContext and RxJS behaves identically on Express 5 and Fastify 5. The only platform-specific part is what switchToHttp().getRequest() and getResponse() return: the Express objects on @nestjs/platform-express, and the Fastify request and reply on @nestjs/platform-fastify. Keep interceptors platform-neutral by reading only what both expose, such as method and url.

What changed recently

  • NestJS 12 packages are ESM-only. The interceptor API (NestInterceptor, CallHandler, @UseInterceptors, APP_INTERCEPTOR) is unchanged; the examples use the .js import extensions the ESM template needs.

  • RxJS 7 is current for Nest. The official page imports operators from rxjs/operators, which still works; RxJS 7.2 and later also export them from rxjs.

  • The official page points to NestJS Observe for automatic per-provider timing as an alternative to a hand-written timing interceptor.

Where the book is outdated

The reference book, published in January 2025 (see the section bibliography), registers its response-envelope interceptor more than once, which double-wraps responses as shown above. It also imports CacheModule, CACHE_MANAGER, CacheInterceptor, @CacheKey and @CacheTTL from @nestjs/common, whereas they live in @nestjs/cache-manager, and it uses an @Interceptor() decorator, which does not exist. Bind interceptors with @UseInterceptors() or APP_INTERCEPTOR.