Request lifecycle in NestJS: who runs first, who blocks the request, and where errors land Intermediate
Estimated reading time: 8 min read
The problem
Section titled “The problem”An interceptor logs id as the string "42" while the handler receives the number 42. Or a request rejected by a guard never shows up in the metric recorded in an interceptor.
Both are consequences of the lifecycle: you need to know which components ran, what shape the data is in, and who catches errors.
Quick map
Section titled “Quick map”| # | Layer | Binding levels and order | Can it stop the request? |
|---|---|---|---|
| 1 | Middleware | Global first, then module-bound middleware | Yes |
| 2 | Guard | global, controller, route | Yes (returns false or throws) |
| 3 | Interceptor (in) | global, controller, route | Yes (doesn’t call next.handle()) |
| 4 | Pipe | global, controller, route, then per-parameter pipes | Yes (throws) |
| 5 | Handler (and services) | ||
| 6 | Interceptor (out) | route, controller, global (reverse of the way in) | |
| 7 | Exception filter | route, controller, global (lowest to highest) | Only handles uncaught errors |
Within the same level, order is declaration order (guard 1 runs before guard 2). This is the order for a successful request; a failing request branches off as shown below.
Three paths
Section titled “Three paths”Loading diagram…
| Path | Layers that ran | Layers skipped |
|---|---|---|
| Success | Middleware, guard, interceptor in, pipe, handler, interceptor out | Filter |
| Guard rejects | Middleware, guard | Interceptor, pipe, handler; metrics in the interceptor never see this request |
| Pipe throws | Middleware, guard, interceptor in, pipe | Handler and the outbound tap |
A real run: global, controller, route
Section titled “A real run: global, controller, route”The order-demo.ts demo places guards, interceptors, pipes and filters at all three levels (global via APP_*, controller, route), each logging its name. Real output on Nest 11:
Success: GET /orders/42 guard global guard controller guard route interceptor global (in) interceptor controller (in) interceptor route (in) pipe global: "42" pipe controller: 42 pipe route: 42 handler received number 42 interceptor route (out) interceptor controller (out) interceptor global (out) -> 200 {"id":42}Three rules follow:
- Guards, interceptors (in) and pipes run global, controller, route.
- Interceptors (out) run in reverse: route, controller, global. It is a nested, onion-like structure.
- Pipes chain: the global pipe receives the string
"42"and returns the number42, so the controller and route pipes receive a number.
The two error branches from the same demo (repeated lines condensed to ...):
Controller guard rejects: GET /orders/42 guard global guard controller route filter caught: Forbidden resource -> 403 {"by":"route"}
Route pipe throws: GET /orders/abc guard global ... guard route interceptor global, controller, route (in) pipe global: "abc" pipe controller: "abc" pipe route: "abc" route filter caught: id must be a number -> 400 {"by":"route"}Filters resolve from lowest to highest: route, controller, then global. In the guard rejection example, the route’s filter still catches the error even though it came from the controller’s guard, because the route has already been determined.
An error is handled by only one filter; once a filter catches it, it is not passed to another. For errors thrown in middleware, only global filters apply, because no route has been selected yet.
Data through each layer
Section titled “Data through each layer”| Layer | Sees id as |
Note |
|---|---|---|
| Middleware, guard, interceptor (in) | The string "42" in req.params.id |
Pipes have not run yet |
| Pipe | Receives "42", returns 42 |
The return value becomes the handler parameter |
| Handler | The number 42 |
req.params.id is still a string; pipes don’t write back |
| Interceptor (out) | The handler’s result | Doesn’t see the transformed parameters |
Consequence: a guard that uses URL parameters to make permission decisions must validate that data itself, not assume a pipe has already done so.
Validation done right
Section titled “Validation done right”Replace the hand-written pipe with ValidationPipe plus a DTO (requires class-validator and class-transformer; this snippet follows the Validation docs and has not been run in the repo because those dependencies are not installed in examples):
export class CreateOrderDto { @IsString() sku: string; @IsInt() @Min(1) quantity: number;}
// main.tsapp.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// controller@Post()create(@Body() dto: CreateOrderDto) {}
@Get(':id')get(@Param('id', ParseIntPipe) id: number) {}transform: true turns the body into a CreateOrderDto instance; whitelist: true strips undeclared fields; ParseIntPipe converts "42" to 42 and returns 400 when it is not a number. Because pipes run after guards, permissions are checked first and validation second.
Choosing a layer for a new feature
Section titled “Choosing a layer for a new feature”| Need | Put it in | Why |
|---|---|---|
| Access logs, a request ID for every request, including rejected ones | Middleware | Runs earliest, independent of the route |
| Permissions based on route metadata | Guard | Has the ExecutionContext |
| Validating and converting parameters | Pipe | Right before the handler |
| Timing, transforming responses | Interceptor, closed out with finalize |
Has both the in and out sides, catches the error branch too |
| Normalizing error responses | Exception filter | Only runs for uncaught errors |
Real-world example
Section titled “Real-world example”Production checklist
Section titled “Production checklist”- Check permissions in guards and validation in pipes; guards must not assume parameters were already cleaned by a pipe.
- Use a global
ValidationPipewithwhitelistand DTOs instead of hand-written validation. - Time requests with
finalizein the interceptor to include the error branch; measure whole requests in middleware. - Know which filter catches what: shared filters at global level, specific filters at controller or route level; middleware errors only get global filters.
- With guards or interceptors at multiple levels, log their names to see the order; remember interceptors (out) run in reverse.
- Open the ALS context in middleware so guards and interceptors can read it.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) When a guard returns false, do pipes run, and what does the interceptor see? (2) With interceptors at global, controller and route level, what is the order on the response side? (3) Which filter catches an error from the controller’s guard first?
Run the demo:
npx --prefix examples ts-node examples/src/nestjs-request-lifecycle/order-demo.ts# or the sample server: cd examples && npm ci && npm run lifecycle-demo