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.
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.
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
Solid arrows are the happy path. The coral path is taken the moment anything throws, from a guard, a pipe or the handler itself.
| Step | Runs | Knows the handler? | Typical job |
|---|---|---|---|
| Middleware | global, then module bound | No | Request id, CORS, body limits, raw logging |
| Guards | global, controller, route | Yes | Auth, roles, feature flags |
| Interceptors | in: global, controller, route. Out: reversed | Yes | Timing, caching, response mapping |
| Pipes | global, controller, route, param | Yes | Validate and transform input |
| Exception filters | route, controller, global | Yes | Shape error responses |
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.
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.
Chooses which middleware to bind, in the order listed.
See the exampleLimits it to paths, methods or whole controllers.
See the exampleCarves out routes such as health checks.
See the exampleGlobal and functional only. No dependency injection.
See the example@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.
Guards
One question, one answer: may this request reach the handler? Return true to let it through, false or throw to stop it.
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.
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.
| Return or throw | Client sees |
|---|---|
true | The request carries on to interceptors |
false | 403 Forbidden resource |
throw new UnauthorizedException() | 401, with your message |
| A Promise or Observable | Awaited, then treated as above |
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.
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
@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.
Changes the response body on the way out.
See the exampleLooks at the result without changing it, for logs and metrics.
See the exampleTurns an error into another error or a fallback value.
See the exampleFails slow handlers with a TimeoutError you can map to 408.
See the examplePipes
The last checkpoint before your handler: validate the input, or turn it into the type you want.
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.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 pipe | Turns | Into |
|---|---|---|
| ParseIntPipe | "42" | 42 or 400 |
| ParseUUIDPipe | "9b1d..." | the same string, checked |
| ParseBoolPipe | "true" | true |
| ParseEnumPipe | "ranked" | an enum member |
| DefaultValuePipe | undefined | your default |
| ValidationPipe | a JSON body | a validated DTO |
Exception filters
Where errors become responses. Filters run only when something throws, and the most specific one wins.
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.
@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.
- Route filter@UseFilters on the method
- Controller filter@UseFilters on the class
- Global filterAPP_FILTER or useGlobalFilters
- Built in filterHttpException or 500
Order in action
A tiny model of the pipeline in plain JavaScript. Run it to watch three requests take three different paths.
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.
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));
$ 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.
| Scope | Middleware | Guards, interceptors, pipes, filters |
|---|---|---|
| Global | app.use() or a module bound for all routes | APP_GUARD, APP_INTERCEPTOR, APP_PIPE, APP_FILTER |
| Controller | forRoutes(Controller) | @UseGuards() and friends on the class |
| Route | forRoutes({ path, method }) | The same decorators on the method |
| Parameter | n/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 to | Use | Why there |
|---|---|---|
| Tag every request with an id | Middleware | Runs first, needs no route metadata |
| Refuse a caller without the right role | Guard | Has the handler metadata and runs before any work |
| Time requests or wrap every body | Interceptor | The only step that sees both the way in and the way out |
| Cache a GET response | Interceptor | Can return early without calling the handler |
| Turn "42" into 42 or reject a body | Pipe | Runs per argument right before the handler |
| Give every error the same JSON shape | Exception filter | Catches anything thrown anywhere after middleware |
| Stop slow handlers | Interceptor with timeout() | Wraps the call and can fail it |