NestJS – The Request Lifecycle

August 15, 20268 min readUpdated 9/5/2026

The previous seven lessons each covered one stage of the Nest pipeline. This one puts them in order, because the order is where most of the genuinely confusing bugs in a Nest application come from — and because knowing it turns "which of these should I use?" into a question with an answer.

The order

A request arrives and passes through, outermost first:

  middleware
    guards
      interceptors  (the code before next.handle())
        pipes
          THE HANDLER
        interceptors  (the code after, inside tap/map)
  exception filters  (if anything above threw)
  response

Two things about that shape are worth fixing in your head. Interceptors appear twice, because they wrap the handler — that is the definition of an interceptor. And filters sit outside everything, because their job is to catch what any other stage threw.

Following one real request

Take PATCH /api/v1/projects/<uuid>/status with a valid token and a body of {"status":"in_progress"}.

1. Middleware. RequestIdMiddleware assigns an id, puts it on the request and on the response header, and calls next(). It has no idea which route this is; it has not been told.

2. Guards. JwtAuthGuard verifies the bearer token and attaches the caller to the request. RolesGuard then reads the route's @Roles metadata with Reflector — this route has none, so it returns true. Guards run in the order listed in @UseGuards.

3. Interceptors, inbound half. LoggingInterceptor records a start time and works out the handler name from the ExecutionContext, then calls next.handle().

4. Pipes. TrimPipe trims the body's strings. ValidationPipe builds an UpdateProjectStatusDto and checks @IsIn(PROJECT_STATUSES). Separately, ParseUUIDPipe validates the projectId parameter.

5. The handler. It calls the service, which loads the project with a row lock, checks the transition table, and updates.

6. Interceptors, outbound half. The tap fires, reads response.statusCode, and writes one line.

7. The response. Nest serialises the returned object to JSON.

Had anything thrown, AllExceptionsFilter would have caught it and produced the body instead.

Six consequences of that order

Everything below follows mechanically, and each one is a bug somebody has spent an afternoon on.

A guard never sees validated input

Pipes run after guards. By the time ValidationPipe builds your DTO, the guards have already decided the request may proceed — so a guard inspecting request.body gets the raw parsed object, untransformed and unchecked.

Which is a good argument for guards deciding on the token and the route rather than the payload. A guard that reads the body is trusting something nothing has validated yet.

A guard rejection is invisible to interceptors

Guards run outside interceptors, so a 401 from JwtAuthGuard never reaches one. The logging interceptor writes nothing for it.

That is why the request id is assigned in middleware — one layer further out — and why the filter does its own logging for failures. Three stages, each covering what the others structurally cannot see:

$ curl -i http://localhost:3001/api/v1/auth/me
HTTP/1.1 401 Unauthorized
x-request-id: 63bed0e4-989f-4a5a-89a6-316c52ef6733

An interceptor DOES see a pipe's rejection

The mirror image, and it surprises people. Pipes run inside interceptors, so a validation failure is thrown within the interceptor's stream and its tap({ error }) observes it:

WARN [HTTP] POST /api/v1/auth/register 400 2ms AuthController.register req=ba10c2bc user=anon

AuthController.register never ran. The interceptor knows its name because Nest resolved the route before any of this began — routing happens first, and every stage after it knows where the request was going.

response.statusCode is wrong on the error path

When an interceptor's error callback fires, the filter has not run yet, so nothing has set the status. It is still 200:

function statusOf(error: unknown): number {
  const status = (error as { getStatus?: () => number })?.getStatus
  return typeof status === 'function' ? status.call(error) : 500
}

The status has to come off the exception itself. Reading it from the response gives you 200 for every failure, and the resulting log is quietly useless.

@Res() takes you out of the pipeline

Injecting the raw response and writing to it yourself means Nest is no longer handling the response — so interceptors expecting a return value see nothing, and an exception filter may find the response already sent. Use @Res({ passthrough: true }) when you only need to set a header or a cookie and want to keep returning a value normally.

Middleware runs even where there is no route

forRoutes('*') covers paths no controller claims, including the static file handler. That is usually what you want for a request id — a 404 is a request too — and it is worth knowing before you put anything expensive there.

Where each stage is bound

The four stages have four registration stories, and the differences are not arbitrary.

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(RequestIdMiddleware).forRoutes('*')
  }
}

Middleware is the odd one out: there is no APP_MIDDLEWARE token, because middleware is handed to the underlying Express application at startup against routes rather than resolved per request.

providers: [
  { provide: APP_FILTER, useClass: AllExceptionsFilter },
  { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],

The filter and the interceptor are providers, which gets them two things: they can inject, and a test that imports AppModule gets them automatically.

app.useGlobalPipes(
  new TrimPipe(),
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: {
      enableImplicitConversion: false,
    },
  }),
)

The pipes are bound on the app instance instead, and that inconsistency is deliberate: useGlobalPipes guarantees left-to-right ordering, and TrimPipe must run before ValidationPipe or it is trimming a value that has already been judged.

It has a real cost, and the end-to-end suite pays it:

app.useGlobalPipes(
  new TrimPipe(),
  new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
)

The same configuration, repeated by hand in the test bootstrap. Without it every DTO decorator is inert and every validation assertion in the suite passes vacuously — a test suite that proves nothing while looking thorough. The filter and interceptor need no such repetition, because they came along with the module.

Ordering within a stage

Once you have several of something, the rules are:

Guards run in the order listed in @UseGuards(A, B). Global guards run before controller-level ones, which run before method-level ones.

Interceptors nest: the first listed is outermost, so its inbound code runs first and its outbound code runs last.

Pipes run left to right, each receiving what the previous returned.

Filters are matched most-specific-first, so @Catch(QueryFailedError) wins over a bare @Catch().

What happens before any of it

Everything above starts once Nest has decided which handler a request is for. Before that: Express's own middleware — the body parsers, the CORS handler, anything registered with app.use — then routing.

Two consequences. A CORS preflight OPTIONS request is answered before your middleware sees it, so it never appears in a request log. And a request matching no route is handled by Nest's 404 path, so guards and interceptors never run for it — though middleware applied with forRoutes('*') does.

Routing happening first is also why an interceptor can name the handler for a request that failed validation. The route was resolved long before the pipe rejected the body.

Where a service call fits

Everything in the diagram is the framework's. Your service call happens inside step 5, and the distinction is worth keeping sharp: the pipeline decides whether a request is well-formed and permitted, and the service decides whether it makes sense.

That is why the checks in ProjectsService.updateStatus — is this project real, is this caller the hired contractor, is this transition legal — are not in a guard even though they are plainly authorization. They need a row. And it is why the transaction wrapping them is in the service too: the pipeline has no concept of one, and an exception thrown inside a transaction rolls it back, which an exception thrown in a guard could not.

Shutdown

The mirror image of startup, and easy to forget. app.enableShutdownHooks() makes Nest call onModuleDestroy and onApplicationShutdown on SIGTERM, so connection pools close and in-flight work can finish.

Without it a container receiving SIGTERM dies immediately, and requests in flight are simply dropped. This application does not enable them because TypeOrmModule registers its own; if you hold anything that needs closing yourself, that call is what makes a deploy not drop requests.

Debugging with the order in mind

Knowing the sequence turns most pipeline bugs into a couple of questions.

My decorator has no effect. Something has to read the metadata. A @Roles with no RolesGuard in @UseGuards is inert, and so is any library decorator whose guard or interceptor was never registered.

My global does not apply in tests. It was bound on the app instance rather than with an APP_* token, so importing the module does not bring it.

Everything returns 403. Guard order. RolesGuard is running before the guard that attaches the user.

My validation is not running. No global ValidationPipe — or there is one, and the DTO parameter has no type annotation for it to work from.

Reading the order off a running application

You do not have to take any of this on trust. Add a console.log to one of each stage, call a route, and the sequence prints itself — which is worth doing once, because a rule you have watched happen is easier to recall than one you read.

The startup log already tells you part of it. InstanceLoader lines are the module graph being built, in dependency order, and RouterExplorer lines are the routing table being assembled. Both finish before the first request arrives, which is the concrete meaning of "resolution happens once".

Choosing a stage

The whole lesson compresses into four questions.

Does it need to run even when a guard rejects the request? Middleware. That is the only thing outside the guards.

Is the answer yes or no? A guard. Authentication, roles, feature flags, anything whose entire output is "may this proceed".

Does it need the outcome — the status, the duration, the returned value? An interceptor. It is the only stage on both sides.

Does it transform or check one parameter? A pipe.

And if the job needs the database or needs to know who is asking in a way that depends on stored data, it is none of them — it belongs in a service, where it can load the row and be tested without an HTTP request.

That covers the pipeline. The rest of this track is what a real application does with it: configuration first.