Skip to content
Knowledge Hub

Request lifecycle in NestJS: who runs first, who blocks the request, and where errors land Intermediate

Estimated reading time: 8 min read

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.

# 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.

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

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:

  1. Guards, interceptors (in) and pipes run global, controller, route.
  2. Interceptors (out) run in reverse: route, controller, global. It is a nested, onion-like structure.
  3. Pipes chain: the global pipe receives the string "42" and returns the number 42, 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.

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.

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.ts
app.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.

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
  1. Check permissions in guards and validation in pipes; guards must not assume parameters were already cleaned by a pipe.
  2. Use a global ValidationPipe with whitelist and DTOs instead of hand-written validation.
  3. Time requests with finalize in the interceptor to include the error branch; measure whole requests in middleware.
  4. Know which filter catches what: shared filters at global level, specific filters at controller or route level; middleware errors only get global filters.
  5. With guards or interceptors at multiple levels, log their names to see the order; remember interceptors (out) run in reverse.
  6. Open the ALS context in middleware so guards and interceptors can read it.

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:

Terminal window
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