Skip to main content

NestJS Request Lifecycle: Complete Guide

Overview

The NestJS request lifecycle is the journey a request takes from entry point to response. Understanding this flow is critical for debugging, optimization, and building robust applications.

Key Players in the Lifecycle

ComponentRoleCommon Use Cases
MiddlewareRuns first, can access raw request/responseCORS headers, compression, body parsing, request ID tracking, rate limiting, security headers (Helmet)
GuardsAuthorization checks, runs after middlewareJWT/token validation, role-based access (RBAC), permission checks, API key validation, owner verification
Interceptors (pre-handler)Pre-processing, request transformationRequest logging, encryption/decryption, input sanitization, context injection, multi-tenant routing
PipesData validation and transformationType coercion (parseInt), DTO validation, UUID validation, enum validation, recursive object transformation
Route HandlerBusiness logic executionCRUD operations, complex workflows, data aggregation, multi-step operations, async processing
Interceptors (post-handler)Response transformationResponse wrapping, encryption, sensitive field filtering, pagination formatting, cache headers
Exception FiltersError handling and response transformationGlobal error handling, validation errors, database errors, timeout errors, structured error logging

The Complete Flow

REQUEST ARRIVES

MIDDLEWARE (ordered globally)

GUARDS (order matters!)

GLOBAL INTERCEPTORS (before handler)

CONTROLLER-LEVEL INTERCEPTORS (before handler)

METHOD-LEVEL INTERCEPTORS (before handler)

GLOBAL PIPES (validation)

CONTROLLER-LEVEL PIPES

METHOD-LEVEL PIPES

ROUTE HANDLER EXECUTES

METHOD-LEVEL INTERCEPTORS (after handler)

CONTROLLER-LEVEL INTERCEPTORS (after handler)

GLOBAL INTERCEPTORS (after handler)

EXCEPTION FILTERS (if error)

RESPONSE SENT

alt text

Detailed Breakdown

1. Middleware

What it does: First step in processing; has direct access to Node.js request/response objects.

// logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
// You can modify req/res here
req.userId = 123; // Custom property
next();
}
}

// app.module.ts
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*'); // Apply to all routes
}
}

Gotcha: Middleware doesn't have access to NestJS dependency injection context directly. You need to inject dependencies separately.


2. Guards

What it does: Authorization layer; determines if a request can proceed.

// auth.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;

if (!token) {
throw new UnauthorizedException('Missing token');
}

// Verify token logic
return true;
}
}

// user.controller.ts
@Controller('users')
export class UserController {
@Get()
@UseGuards(AuthGuard)
getUsers() {
return 'All users';
}
}

Gotcha: Guards execute in order of registration. If you have multiple guards, the first one to return false or throw an exception stops the chain.

// Multiple guards - execution order matters!
@UseGuards(AuthGuard, AdminGuard, PermissionGuard)
getSecretData() {}
// If AuthGuard fails, AdminGuard and PermissionGuard never run

3. Interceptors (Pre-Handler)

What it does: Pre-processes the request before it reaches the handler.

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

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

console.log(`Before handler: ${request.method} ${request.path}`);

return next.handle().pipe(
tap(() => {
const duration = Date.now() - startTime;
console.log(`After handler: Took ${duration}ms`);
}),
);
}
}

// app.module.ts
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: RequestLoggingInterceptor,
},
],
})
export class AppModule {}

Gotcha: Interceptors are executed in a specific order:

  • Global interceptors first (in order of registration)
  • Then controller-level interceptors
  • Then method-level interceptors

The "after handler" phase runs in reverse order. (First In Last Out)


4. Pipes

What it does: Validates and transforms data.

// parse-int.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Invalid integer');
}
return val;
}
}

// user.controller.ts
@Controller('users')
export class UserController {
@Get(':id')
getUser(@Param('id', ParseIntPipe) id: number) {
// id is now guaranteed to be a number
return `User ${id}`;
}
}

Gotcha: Pipes run on each parameter independently. If you have multiple parameters with the same pipe, it runs multiple times.

// Pipe runs 3 times (once per parameter)
@Post()
create(
@Body('name', ValidationPipe) name: string,
@Body('email', ValidationPipe) email: string,
@Body('age', ParseIntPipe) age: number,
) {}

5. Route Handler

@Controller('users')
export class UserController {
constructor(private userService: UserService) {}

@Get(':id')
@UseGuards(AuthGuard)
@UseInterceptors(CacheInterceptor)
async getUser(@Param('id', ParseIntPipe) id: number) {
// At this point:
// - Authorization is confirmed
// - Request is logged
// - ID is validated and typed as number
return this.userService.findById(id);
}
}

6. Interceptors (Post-Handler)

Processes the response after the handler completes.

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

@Injectable()
export class ResponseTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

// Usage
@Controller('users')
@UseInterceptors(ResponseTransformInterceptor)
export class UserController {
@Get()
getUsers() {
return [{ id: 1, name: 'John' }]; // Response gets wrapped
}
}

// Response to client:
// {
// "success": true,
// "data": [{ "id": 1, "name": "John" }],
// "timestamp": "2024-01-15T10:30:00.000Z"
// }

7. Exception Filters

What it does: Catches thrown exceptions and formats the HTTP response.

// all-exceptions.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;

const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';

response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
});
}
}

// Usage examples

// 1) Global filter in main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen(3000);
// 2) Controller-level filter
@Controller('users')
@UseFilters(AllExceptionsFilter)
export class UserController {
@Get()
getUsers() {
throw new Error('boom');
}
}
// 3) Method-level filter
@Controller('users')
export class UserController {
@Get()
@UseFilters(AllExceptionsFilter)
getUsers() {
throw new Error('boom');
}
}

Gotcha: Exception filters only run after an exception is thrown, and they are not a substitute for validation logic in pipes or permission logic in guards.


Execution Context

The ExecutionContext gives you access to different protocol-specific contexts.

import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';

@Injectable()
export class ProtocolAwareInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const type = context.getType(); // 'http', 'rpc', 'ws'

if (type === 'http') {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// HTTP-specific logic
} else if (type === 'rpc') {
const data = context.switchToRpc().getData();
// Microservice-specific logic
} else if (type === 'ws') {
const client = context.switchToWs().getClient();
// WebSocket-specific logic
}

return next.handle();
}
}