Last article in the NestJS Production Guide covered Guards and Authorization, the stage in the request lifecycle that decides whether a request is allowed to proceed at all. Interceptors sit right after guards, and they answer a different question. Guards decide whether a request can proceed. Interceptors decide what wraps around it once it does.
That distinction matters because interceptors are the one piece of the Nest pipeline that touches both sides of a request. A guard runs once, before anything else. A pipe runs once, transforming arguments before the handler sees them. An interceptor wraps the handler itself, which means it can run code before the handler executes and again after it returns, with access to both the request and the eventual response.
That before-and-after shape is exactly why interceptors end up handling logging, response transformation, and caching, three jobs that all need to see what went in and what came out. I want to go through how that wrapping actually works under the hood, build a few interceptors worth having in a real API, and cover where teams reach for interceptors when a simpler tool would have done the job better.
How an interceptor wraps the handler
A NestJS interceptor implements one method, intercept, which receives the ExecutionContext (the same object guards get) and a CallHandler. Calling next.handle() invokes the actual route handler and everything downstream of it, and returns an RxJS Observable. Whatever the interceptor does before that call runs before the handler; whatever it chains onto the observable after runs once the handler has produced a value.
// src/common/interceptors/logging.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
console.log(`${method} ${url} - ${duration}ms`);
}),
);
}
}
The RxJS dependency isn't incidental. Because next.handle() returns an observable rather than a plain promise, you get the full set of RxJS operators for free: tap for side effects that don't change the value, map for transforming it, catchError for intercepting errors before they reach an exception filter. This is worth internalizing early, because most interceptor code you'll write is really RxJS code with a NestJS-shaped entry point.
Applying it is the same UseInterceptors decorator you'd expect, at the method, controller, or global level:
// src/modules/orders/orders.controller.ts
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from '../../common/interceptors/logging.interceptor';
import { OrdersService } from './orders.service';
@Controller('orders')
@UseInterceptors(LoggingInterceptor)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get(':id')
getOne(@Param('id') id: string) {
return this.orders.findById(id);
}
}
Transforming responses without touching every controller
The second common use is shaping the response envelope consistently across an API, without every controller method having to remember to do it. A TransformInterceptor that wraps every successful response in a { data, meta } shape is a good example, because it's the kind of cross-cutting concern that's easy to forget in one controller out of forty if it's left to convention instead of enforced structurally.
// src/common/interceptors/transform.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface Envelope<T> {
data: T;
meta: { requestId: string };
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Envelope<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<Envelope<T>> {
const request = context.switchToHttp().getRequest();
return next.handle().pipe(
map((data) => ({
data,
meta: { requestId: request.id ?? 'unknown' },
})),
);
}
}
The other transformation worth knowing is ClassSerializerInterceptor, which Nest ships out of the box and pairs with @Exclude() and @Expose() from class-transformer. Instead of a plain object envelope, it runs your handler's return value through class-transformer's serialization rules, which is the cleanest way to guarantee a passwordHash or internalNotes field never leaves the API, regardless of which query happened to select it.
// src/modules/users/entities/user.entity.ts
import { Exclude } from 'class-transformer';
export class User {
id: string;
email: string;
@Exclude()
passwordHash: string;
}
// src/modules/users/users.controller.ts
import { Controller, Get, Param, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
getOne(@Param('id') id: string) {
return this.usersService.findById(id);
}
}
The tradeoff with any global response transformation is that it becomes part of your API's public contract. Once clients depend on { data, meta }, changing that shape is a breaking change across every consumer, well beyond the one controller where you made the edit. Decide on the envelope early and treat it with the same discipline as a versioned API surface.
Caching, and why the interceptor layer is a reasonable place for it
Interceptors are also where response caching naturally lives, because caching genuinely needs to see both sides of the request: check the cache before the handler runs, populate it after. Nest ships a built-in CacheInterceptor, but most production APIs end up writing a custom version, because tenant scoping and cache-key construction rarely match the generic defaults.
// src/common/interceptors/redis-cache.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { RedisService } from '../../infra/redis/redis.service';
@Injectable()
export class RedisCacheInterceptor implements NestInterceptor {
private readonly ttlSeconds = 30;
constructor(private readonly redis: RedisService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const request = context.switchToHttp().getRequest();
if (request.method !== 'GET') {
return next.handle();
}
const cacheKey = `cache:${request.user?.orgId}:${request.originalUrl}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return of(JSON.parse(cached));
}
return next.handle().pipe(
tap(async (response) => {
await this.redis.set(cacheKey, JSON.stringify(response), 'EX', this.ttlSeconds);
}),
);
}
}
Two details here are the ones that actually matter in production. First, the cache key includes orgId, not just the URL. Skip that and a cached response from one tenant can be served straight to another. That's a real data leak, the kind that shows up in a security review. Second, the TTL is short and the interceptor is scoped to GET requests only. Caching mutations, or caching reads for long enough that stale data becomes a real user-facing problem, are both mistakes. They tend to surface as confusing support tickets weeks after the code shipped, long after any test suite would have caught them.
I'd also flag the honest tradeoff here: this kind of caching adds a Redis round trip to every cacheable request, which is worth it only when the underlying query is expensive enough to justify it. A cheap, indexed lookup on a small table doesn't need this layer. Reach for it once profiling, or just knowing the query plan, tells you the database call is the actual bottleneck.
What interceptors shouldn't be doing
It's tempting to catch errors inside an interceptor with catchError and reshape them there, since the operator is right there in the RxJS toolkit. Resist that for anything beyond logging or rethrowing. Exception filters are the part of the pipeline purpose-built for turning thrown errors into consistent HTTP responses, and they run after interceptors regardless of what an interceptor does with an error internally. Splitting error-shaping logic between interceptors and filters means two different places can format the same kind of error two different ways, which is exactly the inconsistency filters exist to prevent.
The same caution applies to putting business logic inside an interceptor. An interceptor that decides whether to send a welcome email based on the response payload is business logic wearing infrastructure clothing. It's hard to find later, because nobody thinks to look in common/interceptors/ for domain behavior, and it's hard to test in isolation from the HTTP layer. Keep interceptors doing what only the interceptor layer can do: work that needs to see both sides of the request/response boundary.
Production pitfalls
The most damaging mistake is a cache key that isn't scoped correctly for a multi-tenant system. Any interceptor that caches by URL alone, without organization or user context baked into the key, will eventually serve one tenant's data to another. Treat cache-key construction with the same scrutiny you'd give a SQL query that scopes by tenant ID, because the failure mode is the same kind of data leak.
The second is interceptor ordering when several are stacked on one route. UseInterceptors(A, B) runs A first on the way in and last on the way out, wrapping B the way an outer function wraps an inner one. Get this backwards, say, applying a caching interceptor outside a transformation interceptor, and you end up caching the pre-transformation shape instead of the final response, which quietly breaks the moment a client expects the transformed envelope.
The third is doing genuinely slow work inside an interceptor without noticing, because the code lives away from the controller and doesn't get the same scrutiny during review. An interceptor that calls an external service synchronously inside intercept, before even calling next.handle(), blocks every request that matches its route on that call succeeding. If it needs to be there, keep it fast, add a timeout, and make the failure mode explicit rather than letting a slow dependency silently degrade every response.
The fourth is applying interceptors globally via APP_INTERCEPTOR without accounting for routes that shouldn't have that behavior, health checks and webhooks being the obvious ones. A global response-transforming interceptor that wraps everything in { data, meta } will also wrap a webhook payload a third party expects in its original shape, breaking an integration that has nothing to do with the interceptor's actual intent.
Key takeaways
- Interceptors are the only stage in the Nest pipeline that wraps the handler on both sides, which is why logging, response transformation, and caching all naturally live there.
- `next.handle()` returns an RxJS observable, so `tap`, `map`, and `catchError` are the real toolkit for interceptor logic, not a Nest-specific API.
- Response envelopes and `ClassSerializerInterceptor` are effective for consistency and for guaranteeing sensitive fields never leak, but a shared envelope becomes part of your public API contract once clients depend on it.
- Cache keys must include tenant or user context, not just the URL, or a caching interceptor can leak one tenant's data to another.
- Keep error shaping in exception filters and business logic in services; interceptors that take on either job get hard to find and hard to test.
- Interceptor order matters when several are stacked on a route, and global interceptors need explicit exclusions for routes like health checks and webhooks.
Frequently asked questions
What's the difference between an interceptor and a pipe in NestJS?
A pipe runs once, right before the handler, and its job is validating and transforming the arguments passed into it. An interceptor wraps the handler on both sides, running code before it executes and again after it returns a value, which is why interceptors handle logging, caching, and response shaping rather than argument validation.
Can an interceptor short-circuit a request without calling the handler?
Yes. If an interceptor never calls `next.handle()` and instead returns its own observable, for example with `of(cachedValue)`, the actual route handler never runs. This is exactly the mechanism a caching interceptor uses to serve a cached response.
Do interceptors run for every route, or only where I apply them?
Only where you apply them, either via `@UseInterceptors()` at the method or controller level, or globally through the `APP_INTERCEPTOR` provider token. Global interceptors run for every route unless a route is explicitly excluded, similar to how global guards work.
Should caching live in an interceptor or in the service layer?
HTTP-level response caching, keyed by request URL and tenant, fits naturally in an interceptor because it needs to see the request before the handler runs and the response after. Caching inside the service layer makes more sense when you're caching a specific expensive computation or query result that's reused across multiple different endpoints, not tied to one HTTP response shape.
Why use RxJS operators instead of async/await inside an interceptor?
You can use async/await for setup work before calling `next.handle()`, as the Redis cache example does. But the value coming back from the handler arrives as an observable, and operators like `map`, `tap`, and `catchError` are how you transform, observe, or handle errors on that value without converting it to a promise and losing the composability RxJS gives you when interceptors are stacked.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.