Request Lifecycle
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, published 2026-09-30), Node.js 20.19+ or 22.12+,
TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all checked on 2026-10-02. It pulls the order of execution of the
request pipeline into one place. The order tables below are not copied from the documentation: they are what each component
of the example application on this page logs (NestFactory with Express and with Fastify, Node.js 24.16, Vitest and
supertest). The individual components have their own pages:
Middleware, Guards,
Interceptors, Pipes & Validation
and Exception Filters & Error Handling.
The pipeline at a glance
A request is processed by a fixed sequence of stages. Each stage can be bound at several levels (global, controller, route and, for pipes, parameter), and the binding level decides the order inside the stage. In one sentence: the request goes through middleware, then guards, then the "before" half of the interceptors, then pipes, the handler, the "after" half of the interceptors, and finally, only if something threw, the exception filters.
Three details of this picture are worth remembering before reading the tables:
-
Everything but middleware belongs to the route. Guards, interceptors, pipes and filters are looked up after Nest has matched a controller method. A request that matches no route (a 404) therefore never reaches a guard, interceptor or pipe; only global middleware runs, and the not-found exception is handled by the exceptions layer.
-
Interceptors wrap everything inside them. They are entered before the pipes and left after the handler, and because they operate on an RxJS
Observablethey also see errors thrown by pipes, the handler and services. -
Filters are the only stage that resolves from the most specific level up. Every other stage starts at the global level.
The example application
The application is a small lifecycle feature of the Bookshelf whose only job is to log every stage. A shared mark()
function appends to an array and prints a line; it is a module-level function on purpose, because components created
outside the dependency-injection container (app.use(), app.useGlobal*()) cannot inject a logger provider.
export const trace: string[] = [];
export const mark = (stage: string): void => {
trace.push(stage);
console.log(`[lifecycle] ${stage}`);
};
Each component logs its own name. Guards, interceptors and filters are created by small factory functions so that one class body can be reused at several binding levels; in real code you write plain named classes.
import {
ArgumentMetadata, ArgumentsHost, BadRequestException, CallHandler, CanActivate, Catch,
ExceptionFilter, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor,
NestMiddleware, PipeTransform, Type,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { mark } from './trace.js';
@Injectable()
export class ModuleMiddleware implements NestMiddleware {
use(req: any, _res: any, next: () => void): void {
mark(`middleware:module ${req.method} ${req.url}`);
next();
}
}
export function globalMiddleware(req: any, _res: any, next: () => void): void {
mark('middleware:global (app.use)');
next();
}
export function makeGuard(label: string): Type<CanActivate> {
@Injectable()
class LogGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
mark(`guard:${label}`);
const request = context.switchToHttp().getRequest();
// 403 when the header "x-deny" names this guard
return request.headers['x-deny'] !== label;
}
}
return LogGuard;
}
export function makeInterceptor(label: string): Type<NestInterceptor> {
@Injectable()
class LogInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
mark(`interceptor:${label}:before`);
return next.handle().pipe(
tap({
next: () => mark(`interceptor:${label}:after`),
error: (e: Error) => mark(`interceptor:${label}:error(${e.constructor.name})`),
}),
);
}
}
return LogInterceptor;
}
export class LogPipe implements PipeTransform {
constructor(private readonly label: string) {}
transform(value: unknown, metadata: ArgumentMetadata): unknown {
mark(`pipe:${this.label}[${metadata.type}]`);
return value;
}
}
export class RejectPipe implements PipeTransform {
transform(value: string): string {
mark('pipe:reject');
if (value === 'bad') throw new BadRequestException('rejected by pipe');
return value;
}
}
export class TeapotError extends HttpException {
constructor() {
super('I am a teapot', HttpStatus.I_AM_A_TEAPOT);
}
}
export function makeFilter(label: string, exception?: Type<Error>): Type<ExceptionFilter> {
@Catch(...(exception ? [exception] : []))
class LogFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(error: Error, host: ArgumentsHost): void {
mark(`filter:${label}(${error.constructor.name})`);
const status = error instanceof HttpException ? error.getStatus() : 500;
// Platform-agnostic reply: works for Express, Fastify and for errors thrown in
// Fastify middleware, where the response is still the raw Node.js ServerResponse.
this.httpAdapterHost.httpAdapter.reply(
host.switchToHttp().getResponse(),
{ handledBy: label, message: error.message },
status,
);
}
}
return LogFilter;
}
Source: NestJS: Request lifecycle
The controller binds one component of every kind at the controller level, a second one at the route level, and per-parameter pipes on the three arguments of the handler. The three routes below exercise different exits from the pipeline.
import {
BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Query,
UseFilters, UseGuards, UseInterceptors, UsePipes,
} from '@nestjs/common';
import { LifecycleService } from './lifecycle.service.js';
import { LogPipe, RejectPipe, TeapotError, makeFilter, makeGuard, makeInterceptor } from './components.js';
import { mark } from './trace.js';
const ControllerGuard = makeGuard('controller');
const RouteGuard = makeGuard('route');
const ControllerInterceptor = makeInterceptor('controller');
const RouteInterceptor = makeInterceptor('route');
const ControllerFilter = makeFilter('controller', BadRequestException);
const RouteFilter = makeFilter('route', TeapotError);
@UseGuards(ControllerGuard)
@UseInterceptors(ControllerInterceptor)
@UsePipes(new LogPipe('controller'))
@UseFilters(ControllerFilter)
@Controller('lifecycle')
export class LifecycleController {
constructor(private readonly service: LifecycleService) {}
@Post(':id')
@UseGuards(RouteGuard)
@UseInterceptors(RouteInterceptor)
@UsePipes(new LogPipe('route'))
@UseFilters(RouteFilter)
handler(
@Body(new LogPipe('param:body')) body: { shout?: boolean },
@Param('id', new LogPipe('param:id')) id: string,
@Query('q', new LogPipe('param:query')) _q: string,
) {
mark('handler');
if (id === 'teapot') throw new TeapotError();
if (id === 'boom') throw new Error('unexpected');
return this.service.find(id, body?.shout ?? false);
}
@Get('strict/:id')
strict(@Param('id', new RejectPipe()) id: string) {
mark('handler');
return { id };
}
@Get('forbidden')
forbidden() {
mark('handler');
throw new ForbiddenException();
}
}
import { Injectable } from '@nestjs/common';
import { mark } from './trace.js';
@Injectable()
export class LifecycleService {
find(id: string, shout: boolean): { id: string; shout: boolean } {
mark('service');
return { id, shout };
}
}
Source: NestJS: Request lifecycle
The module binds the global guard, interceptor, pipe and filter through the APP_* tokens (so they take part in
dependency injection) and the module-bound middleware through NestModule.configure(). The one global component that
cannot go through a module, app.use() middleware, is registered in a setup() function that both entry points share.
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { LifecycleController } from './lifecycle.controller.js';
import { LifecycleService } from './lifecycle.service.js';
import { LogPipe, ModuleMiddleware, makeFilter, makeGuard, makeInterceptor } from './components.js';
@Module({
controllers: [LifecycleController],
providers: [
LifecycleService,
// Globally bound through DI: these can inject providers (app.useGlobal* cannot).
{ provide: APP_GUARD, useClass: makeGuard('global') },
{ provide: APP_INTERCEPTOR, useClass: makeInterceptor('global') },
{ provide: APP_PIPE, useValue: new LogPipe('global') },
{ provide: APP_FILTER, useClass: makeFilter('global') },
],
})
export class LifecycleModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(ModuleMiddleware).forRoutes(LifecycleController);
}
}
import { INestApplication } from '@nestjs/common';
import { globalMiddleware } from './components.js';
// Global middleware bound with app.use() runs before any module-bound middleware.
export function setup(app: INestApplication): void {
app.use(globalMiddleware);
}
// main-express.ts
// const app = await NestFactory.create(LifecycleModule);
// setup(app);
// await app.listen(3000);
// main-fastify.ts
// const app = await NestFactory.create<NestFastifyApplication>(LifecycleModule, new FastifyAdapter());
// setup(app);
// await app.listen(3000, '0.0.0.0');
The order in practice
Start the Express variant and send one request; the application writes the stages to its console.
curl -X POST 'http://localhost:3000/lifecycle/42?q=x' -H 'content-type: application/json' -d '{"shout":true}'
{ "id": "42", "shout": true }
The [lifecycle] lines logged for that request, identical on Express and on Fastify, are:
| # | Logged stage | Binding |
|---|---|---|
1 |
|
|
2 |
|
|
3 |
|
|
4 |
|
|
5 |
|
|
6 |
|
|
7 |
|
|
8 |
|
|
9-11 |
|
|
12-14 |
|
|
15-17 |
|
|
18 |
|
|
19 |
|
|
20 |
|
|
21 |
|
the controller method |
22 |
|
the injected |
23 |
|
the |
24 |
|
the |
25 |
|
the |
Reading the table:
-
The global, controller and route pipes each run once per handler parameter, not once per request, and they run on the parameters from the last to the first (
query,param,bodyfor a handler declared asbody, id, q). The global pipe processes all three parameters before the controller pipe starts. This is why aValidationPipecan validate the body, the parameters and the query string with a single global binding, and why a custom global pipe must checkmetadata.typeif it should apply to only one of them. -
Parameter-level pipes come last, again from the last parameter to the first.
-
The "after" interceptors run in reverse order because each interceptor wraps the next one: the route interceptor is the innermost, so its
tap()fires first.
Failure paths
A thrown exception skips the rest of the lifecycle and jumps to the exception filters. The same application, driven through three failing requests, shows what still runs.
| Request | Stages that ran | Handled by |
|---|---|---|
|
middleware (2), guards (3), interceptors before (3), pipes (12 calls), |
|
|
the same as above up to |
|
|
middleware (2), guards (2), interceptors before (2), |
|
|
middleware (2), |
|
|
|
|
Three conclusions follow, and each one is a frequent source of confusion:
-
A guard failure is invisible to interceptors. Guards run before the interceptors are entered, so an interceptor that logs or times every request never sees a request rejected by a guard (or by middleware). If you need to observe those, use middleware or a global exception filter.
-
A pipe failure is visible to interceptors. Pipes run after the interceptors were entered, so an interceptor’s
catchErrorortap({ error })sees validation errors. -
Filters are chosen by exception type and level. The lookup starts at the route, then the controller, then the global level, and the first filter whose
@Catch()matches the exception wins. An exception handled by a lower-level filter is not passed up to the next one; use class inheritance between filters to share behaviour.
Middleware order in detail
Middleware is the one stage that does not depend on a matched route, so its order is defined by when it was registered,
not by where it is attached. The documented rule: first the middleware registered with app.use(), then the
module-bound middleware matched by path; among modules, the @Global() modules first, then the root module, then the
others ordered by their distance from the root module in the import graph. The following application confirms it:
function middlewareFor(name: string) {
@Injectable()
class M implements NestMiddleware {
use(_req: unknown, _res: unknown, next: () => void): void {
mark(`middleware:${name}`);
next();
}
}
return M;
}
@Module({})
export class DeepModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(middlewareFor('DeepModule')).forRoutes('{*splat}');
}
}
@Module({ imports: [DeepModule] })
export class FeatureModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(middlewareFor('FeatureModule')).forRoutes('{*splat}');
}
}
@Global()
@Module({})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(middlewareFor('CoreModule (@Global)')).forRoutes('{*splat}');
}
}
@Module({
imports: [CoreModule, FeatureModule],
controllers: [PingController],
})
export class OrderingModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(middlewareFor('RootModule')).forRoutes('{*splat}');
}
}
GET /ping logged, on both platforms:
middleware:CoreModule (@Global)
middleware:RootModule
middleware:FeatureModule
middleware:DeepModule
Two pitfalls hide here. The wildcard route is written '{splat}' (a named wildcard in braces), because Nest 11 and
later run on Express 5 and Fastify 5, which no longer accept a bare ; the braces make the wildcard optional so it
also matches the root path, whereas '*splat' needs at least one path segment. And the order between two middleware in the same module is
simply the order of the apply() calls (or of the arguments of one apply()).
How each enhancer is bound, and which ones support DI
Guards, interceptors, pipes and exception filters are collectively called enhancers. Each can be bound at four levels and the level you pick also decides whether the component takes part in dependency injection.
| Binding | How | Runs | DI? |
|---|---|---|---|
Global, outside any module |
|
for every route |
No. The binding happens outside the context of any module, so the constructor cannot receive providers. |
Global, through a module |
a provider with the token |
for every route |
Yes. Register it from any module; the module’s providers are available. |
Controller |
|
for every route of that controller |
Yes, when you pass the class (Nest instantiates it); No, when you pass |
Route |
the same decorators on a method |
for that route only |
Same as controller. |
Parameter |
a pipe passed to |
for that argument only |
Same as controller (pass the class for DI, an instance for options). |
Module-bound middleware |
|
for the matched routes |
Yes for class middleware (the constructor receives the module’s providers). Functional middleware has no constructor, so no. |
Platform middleware |
|
for every request, before Nest’s own routing |
No. |
Source: NestJS: Guards - Binding guards, NestJS: Pipes - Global scoped pipes, NestJS: Middleware - Dependency injection
The example module above already shows the DI-friendly global binding; here is the DI-less one next to it, to make the
contrast concrete. Both bind a guard for the whole application, and only the first can inject a ConfigService.
// 1. Through a module: constructor injection works.
@Module({
providers: [{ provide: APP_GUARD, useClass: ApiKeyGuard }],
})
export class SecurityModule {}
// 2. Outside any module: ApiKeyGuard would have to be built by hand, without DI.
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new ApiKeyGuard(/* nothing can be injected here */));
Source: NestJS: Guards - Binding guards
Notes that apply to every enhancer:
-
The
APP_tokens are pseudo-providers.* The framework consumes them during bootstrap; you cannotapp.get()them or inject them into another provider. -
You can register an
APP_token several times*, in the same or in different modules. Every registered component runs for every request, in registration order. -
Order within a level is the order of the arguments:
@UseGuards(Guard1, Guard2)runsGuard1beforeGuard2. Decorators stacked on one target are all collected; the order between different kinds of enhancer (guards before pipes, and so on) is fixed by the lifecycle, not by the order of the decorator lines. -
Binding on the class versus the method. A class-level decorator applies to every method, a method-level one to that method only, and both apply to a method that has both, in the order given in the tables above.
-
Providers injected into an enhancer come from the module that declares or imports it; see Providers & Dependency Injection and Modules.
Pitfalls
Registering a global component twice
An APP_INTERCEPTOR (or any other APP_*) provided from two modules is registered twice, and the component runs twice
per request. This typically happens when a shared module is imported by several feature modules and also provides the
token, or when an interceptor is registered both with APP_INTERCEPTOR and with app.useGlobalInterceptors(). The
logging variant of the example application, providing the same interceptor from TwiceA and TwiceB, logged
interceptor:twice:before
interceptor:twice:before
interceptor:twice:after
interceptor:twice:after
for a single request. Double wrapping of a response-envelope interceptor yields { "data": { "data": … } }, and a
double-counted metrics interceptor doubles your numbers. Provide each global enhancer from exactly one module.
const Twice = makeInterceptor('twice');
@Module({ providers: [{ provide: APP_INTERCEPTOR, useClass: Twice }] })
export class TwiceA {}
@Module({ providers: [{ provide: APP_INTERCEPTOR, useClass: Twice }] })
export class TwiceB {}
Errors thrown from middleware
Middleware runs before a route handler is selected, so the exceptions layer treats an error thrown from it differently:
only global exception filters can handle it, and a controller or route filter never sees it. The module with a failing
middleware bound to GET /broken and a global filter logged, on both platforms:
middleware:CoreModule (@Global)
middleware:RootModule
middleware:FeatureModule
middleware:broken
filter:global(Error)
and the response was 500 {"handledBy":"global","message":"middleware failed"}. The code that triggers it:
@Module({})
export class BrokenMiddlewareModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply((_req: unknown, _res: unknown, _next: () => void) => {
mark('middleware:broken');
throw new Error('middleware failed');
})
.forRoutes('broken');
}
}
Middleware registered with app.use() is handled by the underlying HTTP platform, not by Nest’s middleware module, and
the documentation recommends throwing (or calling next(err)) from middleware bound through MiddlewareConsumer
instead. In the version tested here, an error thrown in an app.use() function was also handed to the global filter on
both platforms, but treat that as an implementation detail rather than a contract.
Filters swallow the lifecycle, and do not chain
When an uncaught exception occurs the rest of the lifecycle is skipped and the request goes straight to the filters. An
exception you catch yourself with try/catch never reaches a filter. A filter that handles an exception ends the
search: if the route filter catches TeapotError, the controller and global filters do not see it (the teapot row in
the failure table). To get a chain, make the controller filter extend the global one (extends BaseExceptionFilter) or
rethrow explicitly.
Asking an interceptor to do a guard’s job (or the reverse)
Because guards run before interceptors, a guard cannot use anything an interceptor attaches to the request, and an interceptor cannot short-circuit authorization it did not set up. Put authentication and authorization in guards, cross-cutting behaviour that needs the response (mapping, caching, timing) in interceptors, and request-shaping that must happen before a guard (for example reading a session) in middleware. The next pitfall is a variant of the same ordering problem.
Reading data too early
A guard sees the raw request: the body has been parsed by the platform but not yet validated or transformed by a pipe,
so a guard that reads request.body.price gets whatever the client sent. Transformed values (class instances, parsed
numbers) exist only from the pipes onward. Conversely, a pipe that needs the authenticated user should read it from a
custom parameter decorator (see Custom Decorators) that the guard filled in,
not from a global variable.
Enhancers and non-HTTP contexts
Global enhancers apply to HTTP routes. A connected microservice in a hybrid application does not inherit them by default,
neither the useGlobal*() ones nor the APP_* ones, unless you opt in with the inheritAppConfig option of
connectMicroservice() and call the useGlobal*() methods before it. NestFactory.createApplicationContext()
does not wrap controller methods with enhancers at all, and global enhancers in lazy-loaded modules do not work properly.
Guards, interceptors and filters see the transport through ExecutionContext and ArgumentsHost, so write them with
context.getType() when they must serve HTTP and another transport.
Source: NestJS: Hybrid application, NestJS: Execution context
Express and Fastify differences
The order of the stages is the same on both platforms: the traces above were identical for the Express and the Fastify application, apart from the points below. The differences are in what the components receive.
| Aspect | Express | Fastify |
|---|---|---|
Middleware arguments |
Express |
the raw Node.js |
|
the full path, e.g. |
relative to the mount point, e.g. |
Response object in a filter or guard |
Express |
|
Portable reply from a filter |
|
the same call, and the only one that works for errors thrown in middleware |
Global middleware on a 404 |
runs |
runs |
Wildcard routes (every route) |
|
|
A filter written with response.status(status).send(…) breaks on Fastify: that call throws
TypeError: response.status is not a function for the middleware error and the request hangs until the client gives up.
Writing the response through HttpAdapterHost (httpAdapter.reply()), as components.ts does, works on both platforms,
and is the pattern the documentation uses in its "catch everything" filter.
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
httpAdapter.reply(ctx.getResponse(), { statusCode: status }, status);
}
}
Where does my code belong?
Use the lifecycle to choose the stage, not the other way round.
| I need to… | Use |
|---|---|
Add a correlation id, parse a cookie, or set a security header for every request, even a 404 |
Middleware ( |
Allow or deny a request based on who the caller is, a role, an API key or a feature flag |
A guard, which receives the |
Time a request, map the response, cache it, retry or time out the handler, translate exceptions |
An interceptor |
Validate or transform an argument (a body, |
A pipe |
Turn an exception into a specific HTTP response |
An exception filter |
Source: NestJS: Request lifecycle
What changed recently
-
Express 5 and Fastify 5 are the platforms in Nest 11 and 12. Route patterns such as the middleware path need named wildcards (
'{*splat}'for every route); the lifecycle itself is unchanged. -
The request-lifecycle page now documents the exceptions layer for middleware. Errors thrown from middleware are handled by the exceptions layer, but only global exception filters apply to them.
-
Hybrid applications can opt in to the global enhancers of the main application with
inheritAppConfiginconnectMicroservice(); without it a connected microservice does not inherit them. -
Standard Schema validation (the
schemaoption on@Body(),@Query()and@Param()) runs at the pipe stage, so its position in the lifecycle is the same asValidationPipe. See Pipes & Validation.
Where the book is outdated
The reference book (see the section bibliography) covers the request lifecycle in its chapter on the advanced building blocks. Its order of execution is still right; what it predates (January 2025 publication, before Nest 12):
-
Express 5 and Fastify 5 require named wildcards in route patterns, so a middleware bound with
forRoutes('*')in older material is writtenforRoutes('{*splat}')today. -
Global enhancers registered with
app.useGlobal*()cannot use dependency injection; theAPP_*tokens are the right default for anything that needs a provider. -
The v12 packages are ESM-only, so examples use
importwith.jsextensions in the current template. -
Errors thrown from middleware and the filters that can handle them are spelled out in today’s lifecycle page.