Part 7: Interceptors and filters
The full request pipeline order
Before diving into individual pieces, here is the exact order NestJS processes a request:
flowchart TD A[Incoming request] B[1. Middleware] C[2. Guards] D["3. Interceptors (pre)"] E[4. Pipes] F[5. Controller handler] G["6. Interceptors (post)"] H[7. Exception filters] I[Outgoing response] A --> B --> C --> D --> E --> F --> G --> H --> IExceptions thrown in steps 1-5 propagate to exception filters. Interceptors wrap steps 3-6 so they can catch exceptions too, before filters handle them.
Interceptors
An interceptor implements NestInterceptor. The intercept() method receives the ExecutionContext (same as guards) and a CallHandler. Calling next.handle() returns an Observable of the response. You can transform it with RxJS operators.
Logging interceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger,} from '@nestjs/common';import { Observable, tap } from 'rxjs';
@Injectable()export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { const req = context.switchToHttp().getRequest(); const { method, url } = req; const start = Date.now();
return next.handle().pipe( tap({ next: () => { const ms = Date.now() - start; this.logger.log(`${method} ${url} ${ms}ms`); }, error: (err) => { const ms = Date.now() - start; this.logger.error(`${method} ${url} ${ms}ms - ${err.message}`); }, }), ); }}Apply globally in main.ts:
app.useGlobalInterceptors(new LoggingInterceptor());Or per controller / per route:
@Controller('users')@UseInterceptors(LoggingInterceptor)export class UsersController {}
@Get(':id')@UseInterceptors(LoggingInterceptor)findOne() {}Response transform interceptor
Wrap every response in a standard envelope:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { Observable, map } from 'rxjs';
export interface ApiResponse<T> { data: T; timestamp: string; path: string;}
@Injectable()export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { intercept( context: ExecutionContext, next: CallHandler, ): Observable<ApiResponse<T>> { const req = context.switchToHttp().getRequest();
return next.handle().pipe( map((data) => ({ data, timestamp: new Date().toISOString(), path: req.url, })), ); }}Response before: { "id": 1, "email": "alice@example.com" }
Response after:
{ "data": { "id": 1, "email": "alice@example.com" }, "timestamp": "2026-05-04T12:00:00.000Z", "path": "/users/1"}Timeout interceptor
Abort requests that take too long:
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';import { Observable, throwError, TimeoutError } from 'rxjs';import { timeout, catchError } from 'rxjs/operators';
@Injectable()export class TimeoutInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { return next.handle().pipe( timeout(5000), catchError((err) => { if (err instanceof TimeoutError) { return throwError(() => new RequestTimeoutException()); } return throwError(() => err); }), ); }}Caching interceptor (manual example)
@Injectable()export class CacheInterceptor implements NestInterceptor { private readonly cache = new Map<string, unknown>();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { const req = context.switchToHttp().getRequest(); const key = req.url;
if (this.cache.has(key)) { return of(this.cache.get(key)); }
return next.handle().pipe( tap((data) => this.cache.set(key, data)), ); }}For production, use @nestjs/cache-manager with Redis rather than an in-memory map.
Exception filters
An exception filter catches exceptions thrown anywhere in the request pipeline and shapes the error response. NestJS has a built-in global exception filter that handles HttpException subclasses, but you often want to customize it.
Built-in HttpException
Throw HttpException or one of its subclasses from anywhere (controllers, services, guards, pipes):
import { HttpException, HttpStatus, NotFoundException, BadRequestException } from '@nestjs/common';
// Convenience subclassthrow new NotFoundException('User not found');
// Manual: equivalent to 404throw new HttpException('User not found', HttpStatus.NOT_FOUND);
// With a custom response bodythrow new HttpException( { statusCode: 404, message: 'User not found', code: 'USER_NOT_FOUND' }, HttpStatus.NOT_FOUND,);Built-in exception classes: BadRequestException (400), UnauthorizedException (401), ForbiddenException (403), NotFoundException (404), ConflictException (409), UnprocessableEntityException (422), InternalServerErrorException (500).
Custom HttpExceptionFilter
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger,} from '@nestjs/common';import { Request, Response } from 'express';
@Catch(HttpException)export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse();
const body = typeof exceptionResponse === 'string' ? { message: exceptionResponse } : exceptionResponse;
const errorBody = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, method: request.method, ...body, };
if (status >= 500) { this.logger.error(`${request.method} ${request.url}`, exception.stack); }
response.status(status).json(errorBody); }}Catching all exceptions (catch-all filter)
import { Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';import { BaseExceptionFilter } from '@nestjs/core';
@Catch() // no argument = catch everythingexport class AllExceptionsFilter extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, }); }}Extending BaseExceptionFilter delegates known exceptions back to the default handler while letting you intercept the rest.
Applying filters
Per route:
@Post()@UseFilters(HttpExceptionFilter)create(@Body() dto: CreateUserDto) {}Per controller:
@Controller('users')@UseFilters(HttpExceptionFilter)export class UsersController {}Globally (preferred for a consistent API):
app.useGlobalFilters(new HttpExceptionFilter());
// Or via dependency injection (supports injecting providers):// In AppModule providers:providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }]Middleware
Middleware in NestJS is express-style. It runs before routing and has no access to the Nest execution context (no guards, interceptors, or pipes).
import { Injectable, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';
@Injectable()export class RequestIdMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { req.headers['x-request-id'] = req.headers['x-request-id'] ?? crypto.randomUUID(); res.setHeader('X-Request-Id', req.headers['x-request-id']); next(); }}Register in a module:
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
@Module({})export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(RequestIdMiddleware) .forRoutes('*'); // or { path: 'users', method: RequestMethod.GET } }}Middleware vs Guard vs Interceptor vs Filter
| Layer | When to use |
|---|---|
| Middleware | Raw HTTP manipulation before Nest runs (CORS, request ID, rate limiting via express middleware) |
| Guard | Yes/no access decisions (auth, roles) |
| Interceptor | Wrap handler execution: logging, response shape, caching, timeout |
| Pipe | Input transformation and validation |
| Filter | Shape error responses after an exception is thrown |
The most common mistake is putting business logic in a guard (it should be in a service) or error shaping in a controller (it should be in a filter).
Gotchas at this stage
- Interceptors receive an Observable:
next.handle()returns an Observable, not a Promise. Use RxJS operators. If you need async logic in the pre-handler phase, returnfrom(asyncFn()).pipe(...). - Global filters registered in
main.tscan’t inject providers: because they’re outside the module system. UseAPP_FILTERin a module’sprovidersarray to get DI. @Catch()without arguments catches everything including non-HTTP errors: TypeORMQueryFailedError, unhandled Promise rejections, etc. Log these carefully; they indicate bugs.- Order of multiple interceptors:
@UseInterceptors(A, B)wraps as A(B(handler)). A’s pre runs first; B’s post runs first. The outermost interceptor sees the final output. - Middleware can’t return values: it must call
next()or end the response. Forgetting to callnext()hangs the request.
What’s next
Part 8 covers WebSockets: @WebSocketGateway, @SubscribeMessage, Socket.io rooms and broadcasting, lifecycle hooks, and real-time event patterns.