NestJS – Interceptors

August 6, 20268 min readUpdated 9/5/2026

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=5439eb65

tap, 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.