An interceptor wraps your handler. It runs code before the handler is called and code after it returns, and it is the only stage of the Nest pipeline that sees both sides of a request.
That is the whole reason it exists, and it is the answer to "should this be middleware or an interceptor?" — if the job needs to know what happened, it is an interceptor.
NestInterceptor
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>context is the same object guards get. next.handle() runs the rest of
the pipeline — remaining interceptors, the pipes, and the handler — and returns an
Observable of whatever the handler produced.
The Observable is what surprises people. Nest uses RxJS here rather than promises because an interceptor genuinely wants a stream: it needs to run code before subscribing, transform values passing through, catch errors, and in the case of server-sent events handle more than one value. For ordinary use you need three operators and nothing more.
A logging interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP')
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
if (context.getType() !== 'http') return next.handle()
const http = context.switchToHttp()
const request = http.getRequest<RequestWithId & { user?: AuthenticatedUser }>()
const response = http.getResponse<Response>()
const startedAt = Date.now()
const handler = `${context.getClass().name}.${context.getHandler().name}`
// ...
return next.handle().pipe(
tap({
next: () => write(response.statusCode),
error: (error: unknown) => write(statusOf(error), error),
}),
)
}
}Everything before return runs on the way in. Everything inside
tap runs on the way out. Both outcomes are reachable, which is the property no other
stage has.
It produces one line per request:
LOG [HTTP] POST /api/v1/auth/login 200 74ms AuthController.login req=62278f4e user=anon
WARN [HTTP] POST /api/v1/auth/register 400 2ms AuthController.register req=ba10c2bc user=anon
LOG [HTTP] GET /api/v1/auth/me 200 0ms AuthController.me req=eda41572 user=5439eb65tap, not map
The choice of operator is a design decision rather than a detail.
tap observes the value and passes it through untouched. map replaces
it. An interceptor whose job is to write a line to stdout should not be able to change what the
caller receives, and using tap makes that structural rather than a matter of
discipline.
Where map genuinely belongs is response shaping:
// Wrapping every successful response in an envelope.
return next.handle().pipe(map((data) => ({ data, requestId: requestIdOf(request) })))That is a legitimate interceptor and a decision worth making deliberately, because it changes every endpoint's contract at once. This application does not do it — its responses are the resources themselves — but a filter does add fields to error bodies, which is the same idea applied where the shape was never part of the resource.
Three other operators cover most of what is left. catchError transforms failures,
though an exception filter is usually the better
place. timeout fails a request that takes too long. And
of(cached) returned instead of calling next.handle() short-circuits
the handler entirely, which is how a cache interceptor works — the handler simply never runs.
The status is not on the response yet
One subtlety worth knowing before it costs you an hour:
function statusOf(error: unknown): number {
const status = (error as { getStatus?: () => number })?.getStatus
return typeof status === 'function' ? status.call(error) : 500
}On the success path, response.statusCode is correct. On the error path it is still
200 — the exception filter has not run yet, so nothing has set it. The status has to come off the
exception itself, which is why the code asks the error for its own status and falls back to 500 for
anything that is not an HttpException.
Interceptors are inside the filter, and this is the practical consequence.
Guard the transport
if (context.getType() !== 'http') return next.handle()An interceptor bound globally runs for every transport an application has. This one is HTTP-only
today, and switchToHttp().getRequest() on a future WebSocket or microservice context
returns something that is not an Express request at all — so the guard is what stops a logging
interceptor from breaking the day someone adds a gateway.
ExecutionContext has switchToRpc() and switchToWs() for
those cases. Handling all three in one interceptor is possible and usually not worth it; checking
the type and doing nothing is.
What an interceptor cannot see
The important limitation: interceptors run inside guards. A request rejected by
JwtAuthGuard never reaches one, so the interceptor above produces no line at all for a
401.
That is the right trade for logging outcomes — but it means the requests you most want to correlate, the ones that failed, are exactly the ones this cannot see. Which is why the request id is assigned by middleware one layer further out, and why the exception filter does its own logging for failures.
Stating the division plainly: middleware runs for everything and knows nothing about the outcome. An interceptor knows the outcome and only runs for requests that got past the guards. Neither is a substitute for the other.
Binding one
Four scopes again — method, controller, app.useGlobalInterceptors(), or a provider
under the APP_INTERCEPTOR token. The last one is what this application uses:
providers: [
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],Two things follow from choosing the token over app.useGlobalInterceptors(), and
both are worth having.
It becomes an ordinary member of the DI graph, so it can inject ConfigService or
anything else — something a globally bound instance constructed with new in
main.ts cannot do.
And a test that builds the application from AppModule picks it up automatically,
because it is part of the module. Anything registered on the app instance in main.ts
has to be repeated in every test's bootstrap, and the day somebody forgets, the suite is testing a
pipeline the production application does not have.
Register the same token more than once and you get more than one interceptor, which is how several globals coexist. They run in the order the providers are declared, and outermost-first — so an interceptor listed earlier wraps the ones after it.
The built-in one you already use
File upload is an interceptor, which is a good reminder that the mechanism is more general than "logging and caching":
@Post('me/portfolio')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: MAX_UPLOAD_BYTES, files: 1 },
}),
)
addPortfolioImage(
@CurrentUser() user: AuthenticatedUser,
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: AddPortfolioImageDto,
) {
return this.contractorsService.addPortfolioImage(user, file, dto.caption)
}FileInterceptor runs multer on the way in and puts the parsed file on the request,
where @UploadedFile() — a parameter decorator, as in
lesson 9 — reads it back. Purely inbound work, and
an interceptor is simply the stage with the right position and the right access.
Lesson 18 covers what those options are protecting
against.
Nest's ClassSerializerInterceptor is the other one you meet early. It runs
class-transformer over the returned object so @Exclude() on an entity
field takes effect — a map-style interceptor, doing exactly what this lesson said such
an interceptor does.
Two more things interceptors are good at
Timeouts. An operator on the stream, so a slow handler becomes a clean 408 rather than a request that hangs until the client gives up:
return next.handle().pipe(
timeout(5000),
catchError((error) =>
error instanceof TimeoutError
? throwError(() => new RequestTimeoutException())
: throwError(() => error),
),
)Worth knowing what it does and does not do: the response is abandoned, and the handler keeps running. A query that takes thirty seconds still takes thirty seconds and still holds its connection. A timeout improves what the client sees; it does not cancel work.
Caching. The short-circuit mentioned above, written out:
const hit = this.cache.get(key)
if (hit !== undefined) return of(hit)
return next.handle().pipe(tap((value) => this.cache.set(key, value)))Returning without calling next.handle() means the pipes and the handler never run at
all. That is genuinely useful and genuinely easy to get wrong — a cache key that omits the caller
serves one user's data to another, which is why Nest's own CacheInterceptor defaults
to caching only GET and lets you override the key.
Errors: interceptor or filter?
Both can act on a thrown exception, and the division is worth stating.
An interceptor is the right place when the handling is specific to these routes — translating one library's error into an HTTP exception for the controller that uses that library, or retrying an idempotent upstream call.
An exception filter is the right place for anything that should be true of the whole application: the response shape, the status code for a database constraint, what gets logged. A filter also catches what guards throw, which an interceptor cannot see at all.
The practical consequence of getting this backwards is error handling that applies to most endpoints and quietly not to the ones whose author forgot the interceptor.
One interceptor, many transports
A last note on why interceptors show up in Nest's WebSocket and microservice documentation as
well. The ExecutionContext abstraction means the same class can serve all three, which
is genuinely useful for something like timing.
In practice most interceptors touch transport-specific things — a status code, a header — and
the honest version is the guard this one uses: check getType(), do the work for the
transport you understand, and pass everything else straight through.
Testing one
An interceptor needs a fake CallHandler as well as a fake context, and both are
small:
const next = { handle: () => of({ ok: true }) }
await firstValueFrom(interceptor.intercept(context, next))of(value) stands in for a handler that succeeded;
throwError(() => new NotFoundException()) stands in for one that threw, which is the
branch worth covering because it is the one with the status-code subtlety above.
The thing to remember is that an Observable does nothing until it is subscribed. A test that
calls intercept() and never subscribes will watch every assertion fail, because
tap never ran — firstValueFrom is what makes it run.
Choosing the stage
Ask what the job needs. Needs the outcome? Interceptor. Needs to run even when a guard rejects? Middleware. Only ever says yes or no? Guard. Only transforms one parameter? Pipe.
Next: middleware — the layer outside all of this, and the one case where "outside" is exactly what you need.