The NestJS request lifecycle, step by step
Every request passes through the enhancers in a fixed order. Knowing that order tells you what state exists at each point and what each layer can still change.
Incoming request --> Middleware (global, then module-bound) --> Guards (global, controller, route) --> Interceptors, before (global, controller, route) --> Pipes (global, controller, route, parameter) --> Controller method --> Interceptors, after (route, controller, global, on the response stream) --> Response
Exception filters catch anything thrown along the way.
Middleware runs first, before Nest has decided which handler will serve the request. It sees the raw request and response objects, but it has no knowledge of the target controller or its decorators.
Guards run once routing has resolved the handler. They receive an execution context that exposes the class, the method, and any metadata attached to them. That access is why authorization belongs here.
Interceptors wrap the handler. Their "before" logic runs ahead of the pipes. Their "after" logic operates on the observable returned by the handler, so they can transform, time, cache, or replace the result.
Pipes run last before the handler. They validate and transform individual arguments.
Exception filters turn errors thrown by guards, interceptors, pipes, or the handler into responses.
Two questions place most logic:
- What does the logic need to know? If the raw request is enough, it can go early. If it needs the resolved handler and its metadata, it has to run at the guard stage or later.
- Must it wrap the handler's result? Only interceptors can see the handler's output and act on it.
Incoming request
v
+---------------------------------------------------+
| Middleware: global -> module-bound | raw req/res only
+---------------------------------------------------+
v routing resolves the handler
+---------------------------------------------------+
| Guards: global -> controller -> route | has handler + metadata
+---------------------------------------------------+
v
+---------------------------------------------------+
| Interceptors (pre): global -> controller -> route | before pipes run
+---------------------------------------------------+
v
+---------------------------------------------------+
| Pipes: global -> controller -> route -> parameter | validate, transform
+---------------------------------------------------+
v
+---------------------------------------------------+
| Controller method (route handler) | returns the result
+---------------------------------------------------+
v result as an Observable stream
+---------------------------------------------------+
| Interceptors (post): route -> controller -> global| map, time, cache
+---------------------------------------------------+
v
Response
+---------------------------------------------------+
| Exception filters: catch anything thrown above | -> error response
+---------------------------------------------------+
The fixed order of NestJS enhancers on a request: middleware runs before the handler is resolved, guards see the handler and its metadata, interceptors wrap the handler on both sides, pipes prepare arguments, and exception filters turn errors thrown along the way into responses.
How each layer works and what context it can see
Each layer is a different interface, and the interface determines what the code can see.
Middleware implements NestMiddleware (or is a plain function) with use(req, res, next). It receives the underlying platform's request and response objects and nothing else. It has no ExecutionContext, so it cannot know which controller or handler will run, because routing to a handler has not happened yet. You bind it in a module's configure() through MiddlewareConsumer, or globally with app.use().
Guards implement CanActivate. canActivate() receives an ExecutionContext and returns a boolean, Promise or Observable. Because the handler is already resolved, a guard can call context.getHandler() and context.getClass() and read decorator metadata with Reflector. Returning false produces a 403 Forbidden response. For any other status, such as a 401 for a missing token, throw the specific exception yourself.
Interceptors implement NestInterceptor. They receive the context and a CallHandler, and they wrap next.handle(). They are the only layer that sees both the inbound call and the outbound result or error stream.
Pipes transform and validate individual arguments. They can reject malformed input, but they do not decide whether the caller may proceed. That decision belongs to guards.
Exception filters catch errors and shape the response. Controller- and route-scoped filters cannot catch errors thrown in middleware, because middleware runs before any route-level binding applies.
| Layer | Lifecycle position | Available context | Access to metadata | Can wrap the response | Typical uses |
|---|---|---|---|---|---|
| Middleware | First, before routing to a handler | Platform req, res, next |
No | No | Body parsing, request IDs, CORS, raw logging |
| Guard | After middleware, before interceptors | ExecutionContext |
Yes, via Reflector |
No | Authentication, role and permission checks |
| Interceptor | Around pipes and the handler | ExecutionContext, CallHandler |
Yes, via Reflector |
Yes | Timing, response mapping, caching, timeouts |
| Pipe | Just before the handler, per argument | Argument value, ArgumentMetadata |
Argument-level only | No | Validation, type conversion |
| Exception filter | When an exception is thrown | Exception, ArgumentsHost |
Indirectly | Writes the error response | Error formatting, error logging |
import { CallHandler, CanActivate, ExecutionContext, Injectable, NestInterceptor, NestMiddleware, SetMetadata, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { Observable, tap } from 'rxjs';
export const Public = () => SetMetadata('isPublic', true);
// Middleware: raw platform objects only. No handler is known yet, so no metadata.
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
res.setHeader('x-request-id', req.header('x-request-id') ?? randomUUID());
next();
}
}
// Guard: the handler is resolved, so Reflector can read decorator metadata.
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [context.getHandler(), context.getClass()]);
if (isPublic) return true;
const req = context.switchToHttp().getRequest<Request>();
// Throw explicitly for 401; returning false would produce a 403.
if (!req.headers.authorization) throw new UnauthorizedException();
return true;
}
}
// Interceptor: wraps next.handle(), so it sees the call and its outcome.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
const name = `${context.getClass().name}.${context.getHandler().name}`;
return next.handle().pipe(tap({ finalize: () => console.log(`${name} ${Date.now() - start}ms`) }));
}
}
A middleware, guard and interceptor side by side, showing that only the guard and interceptor receive an ExecutionContext. The guard reads handler metadata through Reflector, and the interceptor wraps next.handle() to observe when the handler completes.
Choosing the right layer: a decision rule with worked examples
A useful rule: put logic in the earliest layer that has everything it needs, and no earlier.
Correlation ID → middleware. Reading or generating an x-request-id header needs only the raw request, and it should run before anything else so that guard rejections are logged with the ID too. An interceptor would be a worse fit because it runs after guards, so a request rejected by a guard would never receive an ID.
JWT and roles → guard. Authorization needs to know which handler is targeted. A guard receives an ExecutionContext, so it can call Reflector.getAllAndOverride on context.getHandler() and context.getClass() to read an IS_PUBLIC flag or a roles list, then return false to deny. Middleware cannot see that metadata. An interceptor could in principle, but denying access is not what interceptors are for, and a guard's false produces a consistent ForbiddenException.
Handler latency → interceptor. Timing requires wrapping the handler. Record Date.now() before next.handle(), then log the elapsed time in a tap on the returned stream. Middleware sees the request before guards and pipes run, so it measures more than the handler.
Body validation → pipe. Validation operates on a single argument and its declared type. A guard runs before that argument is resolved.
Error JSON shape → exception filter. Only a filter catches exceptions thrown from anywhere in the pipeline, including from guards and pipes. Shaping errors in an interceptor misses those exceptions.
import { CallHandler, CanActivate, ExecutionContext, Injectable, Logger, NestInterceptor, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
// Metadata decorators: the guard reads these, so routes declare intent without extra logic.
export const IS_PUBLIC = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC, true);
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// Handler-level metadata overrides class-level metadata.
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets)) return true;
const required = this.reflector.getAllAndOverride<string[]>('roles', targets);
if (!required?.length) return true; // No @Roles(): authenticated access is enough
// Assumes an earlier JWT guard verified the token and attached req.user.
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role)); // false -> 403 ForbiddenException
}
}
@Injectable()
export class TimingInterceptor implements NestInterceptor {
private readonly logger = new Logger('Timing');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const route = `${context.getClass().name}.${context.getHandler().name}`;
const start = Date.now(); // Runs after guards and pipes, so only the handler is measured
const log = () => this.logger.log(`${route} took ${Date.now() - start}ms`);
return next.handle().pipe(tap({ next: log, error: log })); // Log on success and on failure
}
}
A roles guard that reads @Public() and @Roles() metadata through Reflector.getAllAndOverride, and an interceptor that times only the route handler by wrapping next.handle().
Binding scope, ordering and global registration with APP_* tokens
Guards, interceptors and pipes bind at three scopes. Pass a class or an instance to @UseGuards(), @UseInterceptors() or @UsePipes() on a controller to cover every handler in it, or on a single method to cover one route. Global binding covers every route in the application. Middleware is configured separately, through configure(consumer: MiddlewareConsumer) in a module or app.use() in main.ts.
When several enhancers of the same kind apply, Nest runs them from broad to narrow: global first, then controller, then method. Within one decorator they run in the order you list them, so @UseGuards(AuthGuard, RolesGuard) authenticates before it checks roles. That order matters because RolesGuard usually reads the user that AuthGuard attached to the request. Interceptors wrap the handler, so their post-handler logic runs in reverse order on the way out.
For global registration, app.useGlobalGuards(new AuthGuard()) creates the instance outside any module. The guard cannot receive injected dependencies such as a config service or a token verifier, unless you construct them by hand. The alternative is to register a provider in a module, for example { provide: APP_GUARD, useClass: AuthGuard } in the providers array, with APP_INTERCEPTOR and APP_PIPE working the same way. Nest then builds the enhancer inside the DI container and applies it globally, no matter which module declares it.
The trade-off is visibility. Someone reading main.ts will not see that a global guard exists. To reduce that cost, declare these providers in a predictable place, such as the AppModule or a dedicated core module, and add a comment in main.ts that points to it.
// app.module.ts: single place where global enhancers are registered.
// main.ts should carry a comment pointing here, e.g.
// "Global guards, interceptors and pipes: see AppModule providers."
import { Module, ValidationPipe } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { AuthGuard } from './auth/auth.guard';
import { RolesGuard } from './auth/roles.guard';
import { LoggingInterceptor } from './common/logging.interceptor';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [AuthModule, OrdersModule],
providers: [
// Built by the DI container, so AuthGuard can inject its token
// verifier and config. new AuthGuard() in main.ts could not.
{ provide: APP_GUARD, useClass: AuthGuard },
// Registered after AuthGuard so it runs second and can read
// the user that AuthGuard attached to the request.
{ provide: APP_GUARD, useClass: RolesGuard },
// Wraps every handler. Post-handler logic runs on the way out.
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
// useValue works when the pipe needs options but no injected deps.
{
provide: APP_PIPE,
useValue: new ValidationPipe({ whitelist: true, transform: true }),
},
],
})
export class AppModule {}
// Narrower scopes still use decorators and run after the global ones:
// @UseInterceptors(CacheHeadersInterceptor) // controller scope
// @Controller('orders') export class OrdersController {
// @UseGuards(OwnershipGuard) @Get(':id') // method scope
// findOne() { ... }
// }
An AppModule that registers global guards, an interceptor and a pipe with the APP_ tokens so they are built with dependency injection, with guards registered in the order they should run and a note on how controller- and method-scoped enhancers run after them.*
When to avoid each layer: common misplacements and pitfalls
Middleware for authentication
Middleware runs before Nest resolves the route handler, so it cannot read metadata such as a @Public() decorator. Public-route exceptions end up as path lists or regexes in the middleware config. When someone renames a controller prefix or adds a public endpoint, the list drifts and nothing fails at compile time. Keep token parsing in middleware if you need it, but make the allow/deny decision in a guard that can read handler metadata.
Interceptors and RxJS
Three mistakes come up repeatedly. First, calling next.handle() without returning it, which leaves the request hanging. Second, subscribing to it manually, which breaks the response chain. Third, using a catchError that neither rethrows nor returns an observable, which silently swallows the error. RxJS code is also harder to review for engineers who don't use it daily, so keep interceptors short.
A timing interceptor never sees requests a guard rejects, because the interceptor chain hasn't started yet. If rejected traffic matters for metrics, count it in middleware or an exception filter.
Response-mapping interceptors also have no effect when a handler injects @Res() without passthrough: true, because Nest no longer owns the response. They can also corrupt streamed or file responses by wrapping them.
Ownership checks in guards
A guard that verifies "does this user own order 42?" has to load the order, and then the service loads it again. When authorization depends on the resource's own data, do the check in the service, after the single fetch.
Transport-agnostic enhancers for HTTP, microservices and WebSockets
Platform teams that publish shared libraries benefit most from this. An authentication guard or tracing interceptor written once can protect REST controllers, message handlers and WebSocket gateways. That works only if the enhancer avoids assuming an HTTP request object.
Guards, interceptors, pipes and exception filters all receive a context object, not a raw request. ExecutionContext (and ArgumentsHost for filters) exposes getType(), which returns 'http', 'rpc' or 'ws'. It also exposes three switch methods that return transport-specific accessors:
switchToHttp()returns the request and response.switchToRpc()returns the message payload and transport context.switchToWs()returns the connected client and the event data.
A token guard can branch on getType() to find the credential in each transport:
- HTTP: read the
Authorizationheader from the request. - RPC: read a field from the message payload or metadata.
- WebSocket: read the handshake data on the client.
After extraction, every branch passes the token to the same verification service. Only the extraction step depends on the transport. Treat any unrecognized type explicitly, usually by denying access, so a new transport cannot pass through unchecked.
Middleware does not work this way. You register it through MiddlewareConsumer against routes, and it receives the underlying HTTP framework's req, res and next. It never runs for microservice handlers or gateway events. A library that depends on middleware therefore covers only HTTP.
If you need the same behavior on every transport, use one of these alternatives:
- Move the logic into a guard or interceptor. Correlation IDs, auditing and rate checks all fit.
- Put the core logic in a plain injectable service. Call it from HTTP middleware and from transport-specific enhancers, so each layer stays a thin adapter.
- Use the transport library's own connection hooks. This applies to logic that must run at socket connection time, before any handler exists. Wire those hooks in through a custom adapter rather than through Nest middleware.
Impact on development cost and engineering time
Runtime cost: request scope travels upward
The most expensive placement mistake is often invisible in code review. If a guard or interceptor injects a request-scoped provider, Nest has to make the enhancer request-scoped too. The same happens to every provider that depends on it, and to the controller that uses it. Instead of a singleton built once at bootstrap, each request now builds a fresh instance of that whole dependency chain. A small convenience, such as injecting the request object to read a tenant ID, can quietly add per-request allocation and garbage-collection pressure to every route the enhancer covers. It is usually cheaper to read per-request data from the ExecutionContext the enhancer already receives, and to keep the enhancer itself a singleton.
Maintenance cost: behavior you cannot see
When an enhancer is stacked at global, controller and method level, the effective behavior of a route no longer appears where the route is defined. To answer "what runs before this handler?", an engineer has to check module providers, class decorators and method decorators, then work out the ordering. Every extra layer makes that search slower.
Where correct placement saves time
- Metadata-driven authorization. A guard that reads role metadata from the handler never needs a separately maintained list of protected paths. Rename a route and its protection moves with it.
- One global exception filter. A single filter replaces error formatting copied into each handler, so the response shape changes in one place.
- A few documented global enhancers. When the team agrees on which enhancers are global and records that convention, reviewers only need to check deviations from it. New engineers learn one documented rule instead of having to infer patterns route by route.
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 guards vs interceptors vs middleware, 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 shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.