Request Lifecycle
Every incoming request in NestJS flows through a fixed, well-defined pipeline before it reaches your route handler and again on the way back out. Understanding this order is the single most useful piece of mental model you can build, because it tells you exactly where to put authentication, validation, logging, caching, and error handling. Get the order wrong and you end up validating data that an unauthorized user should never have reached, or transforming a response that was never produced.
The execution order
A request passes through the following stages in this exact sequence. Middleware runs first (Express/Fastify style), then Nest’s enhancers take over: guards decide access, interceptors wrap the call, pipes transform and validate inputs, and finally the handler executes. On the way out, the same interceptors run their post-handler logic, and anything that throws at any stage is caught by exception filters.
Incoming request
│
▼
1. Middleware (global → module-bound)
2. Guards (global → controller → route)
3. Interceptors (pre) (global → controller → route)
4. Pipes (global → controller → route → param)
5. Route handler (your controller method)
6. Interceptors (post) (route → controller → global) ← reversed
7. Exception filters (only if something throws)
│
▼
Outgoing response
Within each binding level, the resolution order is global → controller → route, except for the post-handler portion of interceptors, which unwinds in reverse (route → controller → global) because interceptors wrap the handler like nested function calls.
Where each stage fits
The table below summarizes the responsibility of each stage and the base class or decorator you implement.
| Stage | Purpose | Implement with | Can short-circuit? |
|---|---|---|---|
| Middleware | Low-level request prep (cors, logging, body parsing) | NestMiddleware / function | Yes (don’t call next()) |
| Guards | Authorization — may this request proceed? | CanActivate | Yes (return false / throw) |
| Interceptors (pre) | Bind extra logic before the handler, transform requests | NestInterceptor | Yes (override the stream) |
| Pipes | Validate and transform input arguments | PipeTransform | Yes (throw on invalid) |
| Handler | Your business logic | @Get(), @Post(), etc. | — |
| Interceptors (post) | Transform/format responses, timing, caching | NestInterceptor | Yes |
| Exception filters | Convert thrown errors into HTTP responses | ExceptionFilter | — |
Seeing the order in code
The clearest way to internalize the pipeline is to attach one of each enhancer to a single route and log when it runs. Each piece below is real and runnable against a standard Nest 11 project.
// logging.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggingMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
console.log('1. middleware');
next();
}
}
// auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean {
console.log('2. guard');
return true; // return false here to short-circuit with 403
}
}
// timing.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
console.log('3. interceptor (pre)');
return next.handle().pipe(tap(() => console.log('6. interceptor (post)')));
}
}
// parse-id.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class ParseIdPipe implements PipeTransform {
transform(value: string, _meta: ArgumentMetadata): number {
console.log('4. pipe');
return Number(value);
}
}
// cats.controller.ts
import { Controller, Get, Param, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { TimingInterceptor } from './timing.interceptor';
import { ParseIdPipe } from './parse-id.pipe';
@Controller('cats')
@UseGuards(AuthGuard)
@UseInterceptors(TimingInterceptor)
export class CatsController {
@Get(':id')
findOne(@Param('id', ParseIdPipe) id: number): string {
console.log('5. handler');
return `cat #${id}`;
}
}
Wire the middleware up in the module so it only applies to the cats routes:
// cats.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { LoggingMiddleware } from './logging.middleware';
@Module({ controllers: [CatsController] })
export class CatsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggingMiddleware).forRoutes(CatsController);
}
}
Hitting GET /cats/7 prints the stages in order, with the post-interceptor running after the handler returns:
Output:
1. middleware
2. guard
3. interceptor (pre)
4. pipe
5. handler
6. interceptor (post)
Pipes run after guards and pre-interceptors, not before. This is deliberate: there is no point validating a request body for a user who is not authorized to make the call. Put authorization in guards, never in pipes.
When something throws
Exception filters sit outside the linear flow — they catch errors raised anywhere in stages 2 through 6. If a guard throws ForbiddenException, a pipe throws BadRequestException, or the handler throws, control jumps straight to the matching filter and the post-interceptor logic for that branch may not run.
// http-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
const status = exception.getStatus();
res.status(status).json({ statusCode: status, message: exception.message });
}
}
Middleware runs before Nest’s exception layer is fully in scope for guards/interceptors. Errors thrown inside middleware are handled by the underlying platform (Express/Fastify) error handling, not by a Nest
@Catch()filter, so keep middleware logic defensive.
Global versus scoped binding
The same component can be applied at four levels. Global enhancers run first on the way in; route-level ones run last. The snippet below registers a guard and filter globally in main.ts, which is the most common production pattern for cross-cutting concerns.
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
Best practices
- Put authorization in guards, input validation in pipes, and response shaping in interceptors — respecting the order keeps each concern in the right place.
- Register cross-cutting enhancers (validation, logging, error formatting) globally rather than repeating
@UseGuardson every controller. - Prefer Nest guards/interceptors over middleware when you need access to the
ExecutionContext, DI, or route metadata — middleware only sees the raw request. - Remember the post-interceptor stage unwinds in reverse; order route-level interceptors with that in mind when one depends on another.
- Keep guards fast and side-effect-free — they run before pipes and on every protected request.
- Use a global
ValidationPipewithwhitelist: trueandtransform: trueso DTOs are sanitized and typed consistently across the app.