What the NestJS request lifecycle is and the order it runs in
The NestJS request lifecycle is the fixed sequence of framework layers that every incoming request passes through before and after your route handler runs. You do not configure the order. You only decide which layers to attach and at what scope. For an HTTP request the sequence is:
- Middleware, globally bound first, then module-bound.
- Guards, which decide whether the request may proceed.
- Interceptors (pre-handler), the code that runs before the handler is invoked.
- Pipes, which transform and validate handler arguments.
- Route handler, your controller method.
- Interceptors (post-handler), which operate on the response stream the handler produces.
Exception filters sit outside this line. They catch anything thrown at any step after middleware hands off, including errors from guards, pipes, interceptors, or the handler.
Within each layer, scope controls the order. Guards, interceptors and pipes resolve from global, to controller, to method. The broadest policy runs first, and narrower code refines it. Exception filters resolve in the opposite direction: method, then controller, then global. The most specific filter gets the first chance at an error, and global filters act as the fallback. Once a filter handles an exception, broader filters do not see it.
The order matters because it fixes what each layer can observe:
- A guard runs before any pipe, so it sees raw, unvalidated input.
- A pipe runs after guards, so it never processes a request that authorization rejected.
- Only interceptors wrap the handler, so they can observe both the incoming call and the outgoing result.
Putting logic in the wrong layer usually means it runs too early or too late to have the data it needs.
Incoming HTTP request
|
v
+-----------------------------------+
| 1. Middleware (global -> module) |
+-----------------+-----------------+
v
+-----------------------------------+ +-----------------------+
| 2. Guards: allow or deny |-->| Exception filters |
+-----------------+-----------------+ | |
v | Catch errors thrown |
+-----------------------------------+ | after middleware |
| 3. Interceptors (pre-handler) |-->| |
+-----------------+-----------------+ | Resolve order: |
v | method -> |
+-----------------------------------+ | controller -> |
| 4. Pipes: transform + validate |-->| global |
+-----------------+-----------------+ | |
v | Most specific first; |
+-----------------------------------+ | once handled, broader |
| 5. Route handler |-->| filters don't see it |
+-----------------+-----------------+ | |
v | |
+-----------------------------------+ | |
| 6. Interceptors (post-handler) |-->| |
| on the response stream | +-----------+-----------+
+-----------------+-----------------+ |
v v
HTTP response <----------------- error response
Guards, interceptors, pipes each resolve: global -> controller -> method
The fixed NestJS HTTP request pipeline: middleware, guards, pre-handler interceptors, pipes, handler, and post-handler interceptors run in order. Exception filters catch errors thrown after middleware and resolve from method to controller to global scope.
What each layer can see and decide
The practical way to choose a layer is to ask what information the logic needs and whether it must run before or after the handler. Each hook receives a different view of the request, and that view limits what it can decide.
| Layer | What it receives | Can read handler metadata? | The one job it does well |
|---|---|---|---|
| Middleware | Raw platform request, response, next |
No | Platform-level concerns before routing context exists: body parsing, request IDs, CORS |
| Guard | ExecutionContext |
Yes, via Reflector |
Return a yes/no on whether this handler may run |
| Interceptor | ExecutionContext and CallHandler |
Yes, via Reflector |
Wrap the handler: time it, map its result, cache it, apply timeouts |
| Pipe | One argument value and its ArgumentMetadata |
No (only the parameter's type and source) | Validate or transform a single input |
| Exception filter | The thrown exception and ArgumentsHost |
Not directly | Turn an error into a transport-appropriate response |
The key difference is ExecutionContext. It extends ArgumentsHost with getHandler() and getClass(), which tell guards and interceptors which handler and controller are about to run. That is why a roles check belongs in a guard rather than middleware. Middleware runs before Nest has resolved the handler, so it cannot see the @Roles() decorator.
Pipes see the least. They work on one parameter at a time and know only its source (body, query, param, custom) and declared type. This narrow view is intentional: it keeps validation reusable and stops it from depending on the rest of the request.
Who should use which layer:
- Platform engineers usually own middleware and global filters.
- Feature teams mostly write guards, pipes, and targeted interceptors.
How guards, interceptors and pipes work in code
Guards
A guard implements CanActivate, and canActivate() may return a boolean, a promise of one, or an observable of one, so async checks like a database lookup fit without workarounds. A role guard usually reads metadata set by a custom decorator through Reflector. Using getAllAndOverride lets a method-level role list override one on the controller. If the guard returns false, Nest throws a generic ForbiddenException. Throwing your own HttpException subclass instead, such as UnauthorizedException for a missing token, gives clients a status and message they can act on.
Interceptors
An interceptor receives an ExecutionContext and a CallHandler. Code before next.handle() runs before the handler. Operators piped onto the returned observable run after it:
taprecords timing.mapreshapes the response into an envelope.timeoutaborts slow handlers.catchErrortranslates errors before they reach filters.
If you never call handle(), the handler never runs. This is how cache interceptors short-circuit by returning of(cachedValue).
Pipes
ValidationPipe with transform: true returns an instance of the DTO class and converts primitives, so @Param('id') arrives as a number. Without it, validation still runs, but the handler receives the original plain object.
Order matters. Global pipes run before controller, method and parameter-scoped pipes. Within @Body(PipeA, PipeB), PipeB receives whatever PipeA returned. A parsing pipe placed after validation therefore sees already-validated data, while a parsing pipe placed before it changes what gets validated.
import { CallHandler, CanActivate, Controller, Body, ExecutionContext, Injectable, NestInterceptor, Param, ParseIntPipe, Put, RequestTimeoutException, SetMetadata, UnauthorizedException, UseGuards, UseInterceptors, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, TimeoutError, catchError, map, tap, throwError, timeout } from 'rxjs';
import { UpdateUserDto } from './update-user.dto'; // class decorated with class-validator rules
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
// Method-level @Roles wins over controller-level @Roles
const required = this.reflector.getAllAndOverride<string[]>('roles', [ctx.getHandler(), ctx.getClass()]);
if (!required) return true;
const user = ctx.switchToHttp().getRequest().user;
if (!user) throw new UnauthorizedException('Missing or invalid token'); // 401 instead of generic 403
return required.some((r) => user.roles?.includes(r)); // false -> Nest throws ForbiddenException
}
}
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now(); // runs before the handler; skipping next.handle() means the handler never runs
return next.handle().pipe(
timeout(5000), // abort handlers slower than 5s
tap(() => console.log(`${ctx.getHandler().name} took ${Date.now() - start}ms`)),
map((data) => ({ data })), // wrap every response in an envelope
catchError((err) => throwError(() => (err instanceof TimeoutError ? new RequestTimeoutException() : err))),
);
}
}
@Controller('users')
@UseGuards(RolesGuard)
@UseInterceptors(TimingInterceptor)
export class UsersController {
@Put(':id')
@Roles('admin')
update(@Param('id', ParseIntPipe) id: number, @Body(new ValidationPipe({ transform: true, whitelist: true })) dto: UpdateUserDto) {
return { id, isDto: dto instanceof UpdateUserDto }; // true only because transform: true
}
}
A role guard that reads metadata with Reflector and throws its own 401, plus an interceptor that times, wraps and times out responses with RxJS operators, both applied to a handler whose parameters pass through ParseIntPipe and a transforming ValidationPipe.
Exception filters and the error path
An exception thrown at any point in the lifecycle abandons the rest of the pipeline. If a guard throws, no interceptor, pipe or handler runs. If a pipe throws, the handler never executes. There is one partial exception: an error from the handler or a later stage travels back through the interceptors' response streams first, so an interceptor using catchError can transform or swallow it before any filter sees it. Whatever escapes goes to exception filters. Nest checks filters bound at the method, then the controller, then globally. The first filter whose @Catch() matches handles the error. Filters do not chain.
When no filter of yours matches, the built-in global filter takes over. For an HttpException or one of its subclasses, it sends the exception's status and response body. For anything else, it logs the error and returns a generic 500 with "Internal server error." This prevents stack traces and database messages from reaching clients.
@Catch(NotFoundException) restricts a filter to that type and its subclasses. @Catch() with no arguments catches everything, and that has two consequences. First, you now own the formatting of every error. If the filter does not branch on instanceof HttpException and read getStatus() and getResponse(), a 400 from ValidationPipe will come out as a 500. Second, you also own logging. A common double-logging bug happens when a filter extends BaseExceptionFilter, logs the error itself, and then calls super.catch(), which logs unknown errors again.
A safe catch-all filter logs only non-HTTP errors, at error level, with the request context attached. It passes HttpException status and body through unchanged and maps everything else to a uniform 500 response.
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
// Catch-all filter. It implements ExceptionFilter directly instead of extending
// BaseExceptionFilter, so nothing else logs the error a second time.
// Register in main.ts: app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapterHost)));
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const request = ctx.getRequest();
const response = ctx.getResponse();
if (exception instanceof HttpException) {
// Known errors (404s, ValidationPipe 400s, guard 403s) keep their status and body.
// They are expected client errors, so they are not logged.
const status = exception.getStatus();
const body = exception.getResponse();
httpAdapter.reply(response, typeof body === 'string' ? { statusCode: status, message: body } : body, status);
return;
}
// Unknown errors: log once at error level with request context, and never send details to the client.
const method = httpAdapter.getRequestMethod(request);
const url = httpAdapter.getRequestUrl(request);
this.logger.error(`${method} ${url} failed`, exception instanceof Error ? exception.stack : String(exception));
const status = HttpStatus.INTERNAL_SERVER_ERROR;
httpAdapter.reply(response, { statusCode: status, message: 'Internal server error' }, status);
}
}
A global @Catch() filter that passes HttpException status and body through unchanged and logs only unexpected errors, once, before returning a generic 500.
Tracing one request: success path and validation failure
The setup is a POST /orders endpoint with four pieces:
LoggingInterceptor, registered globally.RolesGuard, applied with@UseGuardson the controller.ValidationPipe, registered globally.HttpErrorFilter, applied with@UseFilterson the handler method.
Registration scope does not change the phase order. Guards run before interceptors even though this guard is controller-scoped and the interceptor is global.
For a valid request from an admin user, the guard returns true. The interceptor then logs → POST /orders and calls next.handle(). Inside that call, the pipe validates and transforms the body, and the handler runs. On the way out, the interceptor logs ← POST /orders 201. The client receives 201 with the created order.
For a request with an invalid body, the guard still passes and the interceptor still logs → POST /orders. ValidationPipe then throws BadRequestException, and the handler never runs. Because pipes execute inside next.handle(), the error comes back through the interceptor's observable as an error notification. A tap that only handles next logs nothing. Use finalize or an error callback if you want a completion line for failed requests. The method-level filter catches the exception and returns 400 with the validation messages.
| Layer | Valid request | Validation failure |
|---|---|---|
RolesGuard |
runs, allows | runs, allows |
| Interceptor (pre) | logs → |
logs → |
ValidationPipe |
transforms body | throws 400 |
| Handler | runs | skipped |
| Interceptor (post) | logs ← 201 |
error path only |
HttpErrorFilter |
not invoked | sends 400 |
Interceptor pre-logic reads request.body before the pipe runs. That body is the raw, unvalidated input, and options such as whitelist have not stripped extra properties yet. Do not log it as trusted data.
import { Body, CallHandler, Controller, ExecutionContext, Injectable, NestInterceptor, Post, UseFilters, UseGuards } from '@nestjs/common';
import { tap } from 'rxjs/operators';
import { IsInt, IsString, Min } from 'class-validator';
import { RolesGuard } from './roles.guard'; // returns true for admin users
import { HttpErrorFilter } from './http-error.filter'; // @Catch(HttpException), writes the JSON error
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const res = ctx.switchToHttp().getResponse();
// Runs after guards but before pipes, so req.body is still raw, unvalidated input.
console.log(`→ ${req.method} ${req.url}`);
return next.handle().pipe(
tap({
next: () => console.log(`← ${req.method} ${req.url} ${res.statusCode}`),
// A BadRequestException from ValidationPipe arrives here, not in `next`.
error: (err) => console.log(`✗ ${req.method} ${req.url} ${err.status ?? 500}`),
}),
);
}
}
class CreateOrderDto {
@IsString() sku!: string;
@IsInt() @Min(1) quantity!: number;
}
@Controller('orders')
@UseGuards(RolesGuard) // controller-scoped, but still runs before the global interceptor
export class OrdersController {
@Post()
@UseFilters(HttpErrorFilter) // method-scoped: handles the pipe's 400 for this route
create(@Body() dto: CreateOrderDto) {
return { id: 'ord_123', ...dto }; // only reached when validation passes
}
}
// main.ts: app.useGlobalInterceptors(new LoggingInterceptor());
// app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
This is the POST /orders setup traced in this section: a global logging interceptor whose tap has both next and error callbacks, a controller-scoped role guard, a global ValidationPipe and a method-scoped exception filter.
Global registration, dependency injection and transports
Two ways to register global components
app.useGlobalGuards(), useGlobalInterceptors(), useGlobalPipes() and useGlobalFilters() take instances that you construct yourself in main.ts. Because they are created outside any module, Nest cannot inject anything into them. If a global auth guard needs ConfigService or a token-verification service, you have to wire those dependencies by hand.
Registering a provider under the APP_GUARD, APP_INTERCEPTOR, APP_PIPE or APP_FILTER token from @nestjs/core avoids this. The class is resolved by the container, so constructor injection works like it does in any other provider. The component still applies globally, whichever module declares it. Put it in the module that owns its dependencies so the relationship is visible.
Beyond HTTP
ExecutionContext is transport-agnostic. switchToHttp(), switchToRpc() and switchToWs() return the request/response pair, the message data and context, or the client and payload. A guard that always calls switchToHttp().getRequest().headers gets nothing useful on a message pattern or gateway event. Branch on context.getType() before you read transport-specific data.
Errors follow the same split. Microservice handlers should throw RpcException and gateways should throw WsException, and filters for those transports extend their own base filters. An HTTP filter that calls response.status() fails outright when it catches an RPC error.
In hybrid applications, components registered with useGlobal* are not applied to connected microservices by default. They need inheritAppConfig when you connect them, so audit every global component for HTTP-only assumptions.
When to avoid each layer, alternatives, and impact on engineering time
Each layer has a cost, and most lifecycle bugs come from putting logic in the wrong one.
| Layer | Avoid it when | Why it misleads | Alternative |
|---|---|---|---|
| Middleware | Authorization depends on route metadata | Runs before route resolution, so it cannot read decorators such as roles | Guard |
| Guard | Rejection should happen before expensive work | Runs after all middleware, including body parsing | Adapter-level hook or early middleware |
| Interceptor | Cache keys or audit logs need validated data | Its pre-handler phase runs before pipes, so it sees raw input | Explicit service call in the handler |
| Interceptor | Handler uses @Res() or streams |
Raw response access bypasses map() |
Shape output in the handler |
| Catch-all filter | You still need diagnostics | It can swallow stack traces unless it logs them | Narrow @Catch() types and log before mapping |
Interceptors have two more traps. If an interceptor never returns next.handle(), or returns an Observable that never completes, the request hangs without any error. Request-scoped providers make every dependent provider in the chain request-scoped, so each request creates new instances along that chain. Profile before you adopt them.
Stacked decorators across global, controller and method scopes spread one route's behavior over several files. A reader cannot see the effective pipeline by reading the handler.
Correct placement saves engineering time because each concern has one home. Auth failures point to guards, shape bugs point to pipes, and response mapping points to interceptors. Each layer can be unit-tested with a stubbed ExecutionContext. Wrong placement turns a one-file fix into a trace across scopes, and the tests have to boot the whole application to reproduce the bug.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs request lifecycle, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.