Foundations Updated 2026-09 View as Markdown

Interceptors

Code that wraps a handler — before and after. Logging, response shaping, caching, timeouts and cleanup all live here.

An interceptor wraps the route handler. It sees the request on the way in, the response on the way out, and — crucially — it runs even when the handler throws or the client disconnects.

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

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

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest();
    const started = Date.now();

    return next.handle().pipe(
      tap({
        next: () => this.logger.log(`${req.method} ${req.url} ${Date.now() - started}ms`),
        error: (err) => this.logger.error(`${req.method} ${req.url} failed: ${err.message}`),
      }),
    );
  }
}

next.handle() returns an Observable of whatever the handler returns. Everything before that call runs before the handler; everything in the .pipe() runs after.

Where interceptors sit#

middleware → guards → interceptors (before) → pipes → HANDLER
           → interceptors (after) → exception filters

That position is what makes them the right place for anything cross-cutting that needs to see the outcome. A guard can only say yes or no before the fact; an interceptor sees what happened.

Registering#

typescript
@UseInterceptors(LoggingInterceptor)     // one handler or one controller
@Get()
findAll() {}
src/app.module.ts
providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }]   // global, with DI
src/main.ts
app.useGlobalInterceptors(new LoggingInterceptor());   // global, no DI

Prefer APP_INTERCEPTOR for anything that needs injected dependencies — the useGlobalInterceptors form constructs the instance itself, so nothing can be injected into it.

Transforming the response#

typescript
export interface Envelope<T> { data: T; timestamp: string }

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

Every handler now returns { data: ..., timestamp: ... } without any handler knowing about it. Useful — and worth deciding once, early, because changing your response envelope later is a breaking change for every client.

The serializer#

The one interceptor you should almost always register globally:

typescript
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
typescript
export class UserEntity {
  id!: string;
  email!: string;

  @Exclude()
  passwordHash!: string;     // now stripped from every response
}

Without the interceptor, @Exclude() does nothing and your password hashes serialise. This pairing is a security control, not a formatting nicety — see the security review.

Timeouts#

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

Caching#

typescript
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}

  async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
    const req = ctx.switchToHttp().getRequest();
    if (req.method !== 'GET') return next.handle();

    const key = `${req.user?.id ?? 'anon'}:${req.url}`;   // include the user!
    const hit = await this.cache.get(key);
    if (hit !== undefined) return of(hit);

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

finalize — the operator worth knowing#

tap fires on success or error. finalize also fires on unsubscribe, which is what happens when the client disconnects mid-response.

typescript
return next.handle().pipe(
  finalize(() => {
    // runs on success, on error, AND on client disconnect
    this.metrics.record(feature, Date.now() - started);
  }),
);

If you are measuring anything — latency, cost, token usage — finalize is the operator that stops abandoned requests going unrecorded. That matters more than it sounds: abandoned work is exactly the category you most want visibility into.

A minimal RxJS vocabulary#

You do not need to learn RxJS to write interceptors. These five cover nearly everything:

typescript
map(fn)                  // transform the response
tap({ next, error })     // side effect, no transformation
catchError(fn)           // handle an error
timeout(ms)              // fail if it takes too long
finalize(fn)             // cleanup, always runs
of(value)                // an Observable of one value — short-circuit the handler

Exercise#

typescript
// Write a `RequestIdInterceptor` that:
//   - reads an `x-request-id` header, generating one if absent
//   - attaches it to the request object
//   - sets it on the response header
//   - logs method, url, request id and duration on completion,
//     including when the client disconnects

Common questions#

Interceptor, guard or middleware?#

Guard for "may this proceed" — it runs first and can reject cleanly. Middleware for anything that only needs the raw request and does not care about the route. Interceptor for anything that needs to see the result: logging, transforming, timing, caching.

Do I need to learn RxJS?#

Only the five operators above. Nest wraps the handler's return value in an Observable for you, and interceptors are the main place it surfaces. Nothing else in a typical Nest application requires it.

Why is my global interceptor not getting its dependencies?#

Because useGlobalInterceptors in main.ts constructs the instance outside the DI container. Register it as an APP_INTERCEPTOR provider in a module instead, and injection works normally.

Get the NestJS agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for NestJS. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.