Skip to content

nest_08_interceptors - Interceptors

Interceptors wrap around your route handlers They run before the handler (like middleware on steroids) and after the handler (which middleware can't do without hacks) Logging , timing , response transformation , caching - interceptors do the jobs that would otherwise pollute your controller methods

what's in here

  • NestInterceptor interface and intercept method
  • Request logging interceptor
  • Response transformation and serialization
  • Caching interceptor pattern
  • Timeout and error handling interceptors
  • Interceptor execution order with guards and pipes

the NestInterceptor interface

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now()
    const request = context.switchToHttp().getRequest()

    console.log(`--> ${request.method} ${request.url}`)

    return next
      .handle()
      .pipe(
        tap(() => console.log(`<-- ${request.method} ${request.url} - ${Date.now() - now}ms`))
      )
  }
}

intercept() receives the ExecutionContext (same as guards) and a CallHandler next.handle() returns an RxJS Observable - call it to continue the request pipeline Code before next.handle() runs before the handler (pre-phase) Code in .pipe() runs after the handler (post-phase)

request timing interceptor

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest()
    const start = Date.now()
    const correlationId = request.headers['x-correlation-id'] || crypto.randomUUID()

    // add correlation ID to response headers
    const response = context.switchToHttp().getResponse()
    response.header('X-Correlation-Id', correlationId)

    return next.handle().pipe(
      tap({
        next: () => {
          const duration = Date.now() - start
          console.log(`[${correlationId}] ${request.method} ${request.url} - ${duration}ms`)

          // slow request alert - security relevant
          if (duration > 5000) {
            console.warn(`[SLOW] [${correlationId}] ${request.method} ${request.url} - ${duration}ms`)
            // slow requests can indicate scanning or DoS attempts
          }
        },
        error: (error) => {
          console.error(`[ERROR] [${correlationId}] ${request.method} ${request.url} - ${error.message}`)
        }
      })
    )
  }
}

Every request gets a correlation ID - trace it through logs Slow requests log a warning - 5+ seconds on a normal endpoint is suspicious Error logging captures details without leaking stack traces to the client

response transformation interceptor

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'

export interface ApiResponse<T> {
  statusCode: number
  message: string
  data: T
  timestamp: string
  path: string
}

@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
    const request = context.switchToHttp().getRequest()

    return next.handle().pipe(
      map(data => ({
        statusCode: context.switchToHttp().getResponse().statusCode,
        message: 'success',
        data,
        timestamp: new Date().toISOString(),
        path: request.url
      }))
    )
  }
}

// applying globally
// main.ts: app.useGlobalInterceptors(new ResponseInterceptor())

Every response is consistently formatted Frontends don't need to guess the response shape - it's always { statusCode, message, data, timestamp, path } Makes API versioning easier because breaking changes to the data shape don't break the wrapper

caching interceptor

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable, of } from 'rxjs'
import { tap } from 'rxjs/operators'

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, { data: any; expiry: number }>()

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest()
    const key = `${request.method}-${request.url}`

    // check cache
    const cached = this.cache.get(key)
    if (cached && cached.expiry > Date.now()) {
      console.log(`[CACHE HIT] ${key}`)
      return of(cached.data)
    }

    return next.handle().pipe(
      tap(data => {
        console.log(`[CACHE MISS] ${key}`)
        this.cache.set(key, {
          data,
          expiry: Date.now() + 30000  // 30 second TTL
        })
      })
    )
  }
}

// use on specific routes
@UseInterceptors(CacheInterceptor)
@Get('public-data')
getPublicData() {
  return this.service.getExpensiveData()
}

In-memory cache for demonstration - use Redis for production Cache hits skip the handler entirely (and any downstream processing) Cache carefully: never cache authenticated responses or user-specific data

timeout interceptor

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

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly timeoutMs: number = 10000) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(this.timeoutMs),
      catchError(err => {
        if (err instanceof TimeoutError) {
          console.warn(`[TIMEOUT] Request exceeded ${this.timeoutMs}ms`)
          return throwError(() => new RequestTimeoutException('request timed out'))
        }
        return throwError(() => err)
      })
    )
  }
}

Prevents slow requests from hanging your server indefinitely Returns a proper HTTP 408 response instead of letting the connection timeout silently Aggressors abusing slow endpoints get a clean rejection instead of tying up connections

interceptors vs middleware

Middleware             Interceptors
-------               -----------
Runs on Express level  Runs on Nest level
No access to DI        Full DI access
Request/response only  ExecutionContext + metadata
Can't read @Body       Can access transformed body
Pre-handler only       Pre AND post handler

Middleware for low-level concerns (logging raw requests , CORS , security headers) Interceptors for application-level concerns (response formatting , timing , caching , transformation)

applying interceptors

// method-level
@UseInterceptors(LoggingInterceptor)
@Get('users')
findAll() {}

// controller-level
@UseInterceptors(TimingInterceptor, ResponseInterceptor)
@Controller('api')
export class ApiController {}

// global (in main.ts)
app.useGlobalInterceptors(new LoggingInterceptor(), new ResponseInterceptor())

// global (through DI - preferred for testability)
// app.module.ts
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }
  ]
})

APP_INTERCEPTOR token registers the interceptor globally through DI Global interceptors through DI are testable - the DI container creates them with their dependencies Global interceptors in main.ts don't have DI access - avoid for anything that needs services

prerequisites

nest_07_guards - Guards & Authorization


next -> nest_09_pipes - Pipes & Validation