NestJS Lifecycle 0/7

Backend cheatsheetNestJS request lifecycleKnow where your code runs

NestJS
Lifecycle

Middleware, guards, interceptors, pipes and filters all touch the same request. This cheatsheet shows the order they run in, what each one can see, and which one a job belongs to.

NestJS 11TypeScript10 min read
01

The whole trip

Every request passes the same checkpoints in the same order. Learn the order once and you always know where a piece of code belongs.

LifecycleMust knowOrderError path

A request enters through middleware, is allowed or refused by guards, is wrapped by interceptors, has its input checked by pipes, and only then reaches your route handler. On the way out it passes back through the same interceptors in reverse. If anything throws along the way, an exception filter turns the error into a response.

Picture it: one request, start to finish

Incoming requestMiddlewareglobal, then moduleGuardsglobal, controller, routeInterceptors, beforeglobal, controller, routePipesglobal, controller, route, paramRoute handlercontroller, then serviceInterceptors, afterroute, controller, globalResponseException filtersroute, controller, globalanything throwserror body

Solid arrows are the happy path. The coral path is taken the moment anything throws, from a guard, a pipe or the handler itself.

StepRunsKnows the handler?Typical job
Middlewareglobal, then module boundNoRequest id, CORS, body limits, raw logging
Guardsglobal, controller, routeYesAuth, roles, feature flags
Interceptorsin: global, controller, route. Out: reversedYesTiming, caching, response mapping
Pipesglobal, controller, route, paramYesValidate and transform input
Exception filtersroute, controller, globalYesShape error responses
02

Middleware

Plain Express style functions that see the raw request before Nest has picked a route. Use them for work that does not care which handler runs.

First inNestMiddlewareforRoutesNo DI context

Middleware runs before routing is resolved, so it gets req, res and next, but no ExecutionContext and no idea which controller method is coming. That makes it the right home for request ids, raw body limits and access logs, and the wrong home for anything that needs route metadata like roles.

apply(...middleware)

Chooses which middleware to bind, in the order listed.

See the example
Module bound
forRoutes(path | controller)

Limits it to paths, methods or whole controllers.

See the example
Scope
exclude(...routes)

Carves out routes such as health checks.

See the example
Skip list
app.use(fn)

Global and functional only. No dependency injection.

See the example
Global
request-id.middleware.tsTypeScript
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const id = req.header('x-request-id') ?? randomUUID();
    req.headers['x-request-id'] = id;
    res.setHeader('x-request-id', id);
    next(); // forget this and the request hangs forever
  }
}

@Module({ controllers: [GamesController] })
export class GamesModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(RequestIdMiddleware)
      .exclude({ path: 'health', method: RequestMethod.GET })
      .forRoutes(GamesController);
  }
}

Why it matters: every later step, and every log line, can rely on one request id without knowing where it came from.

Class middlewareconsumer.apply(RequestIdMiddleware)Lives in a module, gets dependency injection, can be scoped to routes.
Global functionapp.use(helmet())Runs for every route, set up in main.ts, no injection.
03

Guards

One question, one answer: may this request reach the handler? Return true to let it through, false or throw to stop it.

AuthCanActivateReflectorAPP_GUARD

Guards run after all middleware and before any interceptor or pipe. Unlike middleware they receive an ExecutionContext, so they can read the metadata you put on the handler with decorators. Returning false makes Nest throw a ForbiddenException (403); throw your own exception for anything else, like a 401.

roles.guard.tsTypeScript
export const Roles = Reflector.createDecorator<string[]>();

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const roles = this.reflector.getAllAndOverride(Roles, [ctx.getHandler(), ctx.getClass()]);
    if (!roles) return true; // no roles on the route: open
    const user = ctx.switchToHttp().getRequest().user;
    if (!user) throw new UnauthorizedException();
    return roles.some((r) => user.roles.includes(r));
  }
}

@Roles(['admin'])
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {}

Why it matters: the rule lives on the route as data, and one guard enforces it everywhere.

useGlobalGuardsapp.useGlobalGuards(new RolesGuard(r))Created by you, outside any module, so it cannot inject providers.
APP_GUARD{ provide: APP_GUARD, useClass: RolesGuard }Created by Nest inside a module, with full dependency injection.
Return or throwClient sees
trueThe request carries on to interceptors
false403 Forbidden resource
throw new UnauthorizedException()401, with your message
A Promise or ObservableAwaited, then treated as above
04

Interceptors

Code that wraps the handler: it runs before, sees the result after, and can change either. The only step that works on both sides.

AroundMust knowRxJSCallHandler

An interceptor gets the context and a CallHandler. Anything before next.handle() runs on the way in; operators piped onto it run on the way out. Several interceptors nest like an onion: the global one is outermost, so it starts first and finishes last.

Picture it: interceptors wrap the handler

TimingInterceptor (global)CacheInterceptor (controller)TransformInterceptor (route)handler()before: global, controller, routeafter: route, controller, global
timing.interceptor.tsTypeScript
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  private readonly log = new Logger('HTTP');

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = performance.now();
    const { method, url } = ctx.switchToHttp().getRequest();
    return next.handle().pipe(
      map((data) => ({ data })), // wrap every body in one envelope
      tap(() => this.log.log(`${method} ${url} ${Math.round(performance.now() - start)}ms`)),
    );
  }
}

Why it matters: timing, caching and response envelopes live in one place instead of in every controller.

map(fn)

Changes the response body on the way out.

See the example
Transform
tap(fn)

Looks at the result without changing it, for logs and metrics.

See the example
Side effect
catchError(fn)

Turns an error into another error or a fallback value.

See the example
Errors
timeout(ms)

Fails slow handlers with a TimeoutError you can map to 408.

See the example
Deadline
05

Pipes

The last checkpoint before your handler: validate the input, or turn it into the type you want.

InputValidationPipeParseIntPipeclass-validator

Pipes run after interceptors have started and right before the handler is called, once per decorated argument. A pipe either returns the value, possibly transformed, or throws, usually a BadRequestException. Parameter pipes are processed from the last parameter to the first.

Picture it: a route parameter through ParseIntPipe

"42"ParseIntPipe→42·"abc"ParseIntPipe→400Strings from the URL become numbers, or the request stops with a 400 before your code runs.
create-game.dto.tsTypeScript
export class CreateGameDto {
  @IsIn(['classic', 'ranked']) mode: string;
  @IsInt() @Min(2) @Max(8) players: number;
  @IsOptional() @IsString() @MaxLength(40) name?: string;
}

// main.ts
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,            // strip fields the DTO does not declare
  forbidNonWhitelisted: true, // or reject them with a 400
  transform: true,            // plain JSON becomes a CreateGameDto instance
}));

Why it matters: handlers receive typed, checked data, and unknown fields never reach your database.

Built in pipeTurnsInto
ParseIntPipe"42"42 or 400
ParseUUIDPipe"9b1d..."the same string, checked
ParseBoolPipe"true"true
ParseEnumPipe"ranked"an enum member
DefaultValuePipeundefinedyour default
ValidationPipea JSON bodya validated DTO
06

Exception filters

Where errors become responses. Filters run only when something throws, and the most specific one wins.

Errors@CatchArgumentsHostAPP_FILTER

Filters are the one step that resolves from the inside out: route filters are tried first, then controller, then global. The first filter whose @Catch() matches the error handles it, and no other filter sees it. With no filter of your own, Nest’s built in one turns an HttpException into its status and message, and anything else into a 500.

problem.filter.tsTypeScript
@Catch()
export class ProblemFilter implements ExceptionFilter {
  catch(err: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const req = ctx.getRequest<Request>();
    const status = err instanceof HttpException ? err.getStatus() : 500;
    ctx.getResponse<Response>().status(status).json({
      status,
      error: err instanceof HttpException ? err.message : 'Internal error',
      path: req.url,
      requestId: req.headers['x-request-id'],
    });
  }
}

Why it matters: every error leaves in one shape with a request id, and internal messages never reach the client.

  1. Route filter@UseFilters on the method
  2. Controller filter@UseFilters on the class
  3. Global filterAPP_FILTER or useGlobalFilters
  4. Built in filterHttpException or 500
07

Order in action

A tiny model of the pipeline in plain JavaScript. Run it to watch three requests take three different paths.

RunnableHappy path403404

This is not Nest itself, just the same rules in forty lines: middleware first, guards next, interceptors wrapping the pipe and the handler, and filters tried from the most specific out. Press Run and compare the three traces.

lifecycle-model.jsJavaScript
const trail = [];
const say = (s) => trail.push(s);
const fail = (msg, status) => Object.assign(new Error(msg), { status });

const middleware = [() => say('middleware  requestId'), () => say('middleware  parse token')];
const guards = [() => (say('guard       throttle'), true), (req) => (say('guard       roles'), req.role === 'admin')];
const interceptors = ['global timing', 'route transform'];
const pipes = [(v) => (say('pipe        ParseIntPipe'), Number(v))];
const filters = [
  (e) => (e.status === 404 ? 'route filter    404 ' + e.message : null),
  (e) => 'global filter   ' + (e.status || 500) + ' ' + e.message,
];

function handle(req, handler) {
  trail.length = 0;
  try {
    middleware.forEach((m) => m(req));
    for (const g of guards) if (!g(req)) throw fail('Forbidden resource', 403);
    const call = interceptors.reduceRight((next, name) => () => {
      say('before      ' + name);
      const out = next();
      say('after       ' + name);
      return out;
    }, () => handler(pipes.reduce((v, p) => p(v), req.id)));
    call();
    say('response    200');
  } catch (e) {
    for (const f of filters) { const r = f(e); if (r) { say(r); break; } }
  }
  return trail.join('\n');
}

const findGame = (id) => {
  say('handler     findGame(' + id + ')');
  if (id !== 42) throw fail('Game not found', 404);
};

console.log('# admin asks for game 42');
console.log(handle({ role: 'admin', id: '42' }, findGame));
console.log('# player asks for game 42');
console.log(handle({ role: 'player', id: '42' }, findGame));
console.log('# admin asks for game 7');
console.log(handle({ role: 'admin', id: '7' }, findGame));
TerminalOutput
$ node lifecycle-model.js
# admin asks for game 42
middleware  requestId
middleware  parse token
guard       throttle
guard       roles
before      global timing
before      route transform
pipe        ParseIntPipe
handler     findGame(42)
after       route transform
after       global timing
response    200
# player asks for game 42
middleware  requestId
middleware  parse token
guard       throttle
guard       roles
global filter   403 Forbidden resource
# admin asks for game 7
middleware  requestId
middleware  parse token
guard       throttle
guard       roles
before      global timing
before      route transform
pipe        ParseIntPipe
handler     findGame(7)
route filter    404 Game not found

Why it matters: a refused request never reaches an interceptor or pipe, and a thrown error skips every after step.

ScopeMiddlewareGuards, interceptors, pipes, filters
Globalapp.use() or a module bound for all routesAPP_GUARD, APP_INTERCEPTOR, APP_PIPE, APP_FILTER
ControllerforRoutes(Controller)@UseGuards() and friends on the class
RouteforRoutes({ path, method })The same decorators on the method
Parametern/a@Body(ValidationPipe), @Param("id", ParseIntPipe)

Which one do I need?

Match the job to the step. When two could work, pick the one that runs latest while still having what it needs.

I want toUseWhy there
Tag every request with an idMiddlewareRuns first, needs no route metadata
Refuse a caller without the right roleGuardHas the handler metadata and runs before any work
Time requests or wrap every bodyInterceptorThe only step that sees both the way in and the way out
Cache a GET responseInterceptorCan return early without calling the handler
Turn "42" into 42 or reject a bodyPipeRuns per argument right before the handler
Give every error the same JSON shapeException filterCatches anything thrown anywhere after middleware
Stop slow handlersInterceptor with timeout()Wraps the call and can fail it