What are interceptors in NestJS?
NestJS interceptor is a class that wraps route handler execution using RxJS Observables, running custom logic both before the handler fires and after it responds.
Theory
TL;DR
- Interceptors sit in the pipeline after guards and wrap the handler on both sides
next.handle()returns anObservable; everything inside.pipe()runs post-handler- Analogy: a security checkpoint that scans your bag before boarding and stamps it after you land
- Use them for logging, response wrapping, caching, and exception mapping across multiple routes
- Not a replacement for guards (auth) or pipes (input validation); those run at different stages
Quick example
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<any> {
console.log('Before handler');
const now = Date.now();
return next.handle().pipe(
tap(() => console.log(`After handler. Elapsed: ${Date.now() - now}ms`)),
);
}
}Code before next.handle() runs first. The tap callback runs after the handler completes. That split is the entire model.
Key difference vs middleware
Express middleware operates on raw req and res objects in a linear chain before the handler. Interceptors wrap the handler's Observable directly, giving you async stream control: retry, timeout, catchError, all composable with standard RxJS operators. Post-handler logic is cleaner here because you react to what the handler actually returned, rather than modifying res imperatively after the fact.
When to use
- Global response envelope → interceptor (wrap every response in
{ data, success, timestamp }) - Execution time logging across all routes → interceptor
- Cache hit short-circuit → interceptor (
of(cached)skips the handler entirely) - Exception type mapping (DB error to HTTP 503) → interceptor with
catchError - Auth check → guard (runs before interceptors, short-circuits cleanly)
- Input validation → pipe
How it works internally
When a request arrives, NestJS resolves the interceptor chain via reflection metadata and calls each intercept() in registration order. CallHandler.handle() triggers the actual route handler when subscribed. Your .pipe() sits on top of that Observable. Errors travel through throwError(), which is why catchError catches handler failures but tap does not.
Global interceptors registered with APP_INTERCEPTOR run in the order they appear in providers. Worth knowing if you have a transform interceptor and a logging interceptor where order affects what actually gets logged.
Applying interceptors
// Method level
@Get()
@UseInterceptors(LoggingInterceptor)
findAll() { ... }
// Controller level
@Controller('users')
@UseInterceptors(TransformInterceptor)
export class UsersController {}
// Global via module (supports DI)
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
})
export class AppModule {}Request pipeline position
Request
→ Middleware
→ Guards
→ Interceptors (pre-handler)
→ Pipes
→ Route Handler
→ Interceptors (post-handler)
→ Exception Filters
ResponseCommon mistakes
Mistake 1: Calling .then() on next.handle()
// Wrong
return next.handle().then(data => ({ wrapped: data })); // TypeError at runtimenext.handle() returns an Observable, not a Promise. Use .pipe(map(...)) instead. This is the most common NestJS interceptor error on Stack Overflow.
Mistake 2: Using tap() for error handling
// Wrong - errors from the handler never reach tap()
return next.handle().pipe(
tap(() => { throw new Error('boom'); }),
);tap only fires on successful emissions. Handler errors bypass it completely. Use catchError for anything on the error path, otherwise production errors go unlogged.
Mistake 3: Accidentally skipping the handler
// Missing return next.handle() on cache miss
async intercept(ctx: ExecutionContext, next: CallHandler) {
const cached = await this.cache.get(key);
if (cached) return of(cached);
// forgot: return next.handle().pipe(...)
}This silently returns undefined for every non-cached request. Always make sure both branches return something.
Mistake 4: Forgetting the Promise<Observable> return type for async intercept
// Wrong return type annotation
async intercept(ctx, next): Observable<any> { ... } // TypeScript complains
// Correct
async intercept(ctx, next): Promise<Observable<any>> { ... }The moment you add async to intercept(), the return type becomes Promise<Observable<any>>, not Observable<any>. TypeScript will catch this, but it confuses devs who mix async/await with Observables for the first time.
Real-world usage
- NestJS boilerplates →
TransformInterceptorfor consistent{ success: true, data }envelope on every endpoint - Prisma + NestJS setups → query timing interceptor to surface slow queries in logs
- BullMQ job processors → global interceptor to normalize job result shapes
- Error monitoring (Sentry, Datadog) → global
catchErrorinterceptor before exception filters catch anything
Follow-up questions
Q: Where exactly do interceptors sit relative to guards and pipes?
A: After guards on the way in, after the handler on the way out. Guards can reject a request before an interceptor ever runs. Pipes run between interceptors and the handler.
Q: Why RxJS instead of plain async/await?
A: Observables let you compose retry, timeout, and error recovery declaratively. With callbacks you would wire those manually. The timeout operator alone justifies the dependency for any production API.
Q: How do you apply an interceptor only to POST requests?
A: Check context.switchToHttp().getRequest().method inside intercept() and call next.handle() conditionally. Or use @UseInterceptors() only on the POST-decorated method at the controller level.
Q: What is the overhead of a global interceptor?
A: Around 1-2ms per request for typical transform or logging work. Measure it with a timing interceptor before treating it as a problem.
Q (senior): Implement a timeout interceptor for slow DB queries.
A: return next.handle().pipe(timeout(5000), catchError(() => { throw new HttpException('Request timeout', 408); })). The timeout RxJS operator emits an error if the Observable does not complete within the given milliseconds, which you then map to an HTTP response.
Examples
Basic: request logging
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<any> {
const req = context.switchToHttp().getRequest();
const now = Date.now();
return next.handle().pipe(
tap(() => {
console.log(`${req.method} ${req.url} - ${Date.now() - now}ms`);
}),
);
}
}Register globally via { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }. Every route now logs method, path, and elapsed time after the handler returns, with zero changes to individual controllers.
Intermediate: API response envelope
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface Response<T> {
data: T;
success: boolean;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
data,
success: true,
timestamp: new Date().toISOString(),
})),
);
}
}
// GET /products
// Before: [{ id: 1, name: 'Keyboard' }]
// After: { data: [{ id: 1, name: 'Keyboard' }], success: true, timestamp: "2024-..." }Apply this globally and every endpoint returns a consistent shape. Frontend code can always read response.data without handling different formats per endpoint.
Advanced: cache interceptor with short-circuit
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { HttpException, HttpStatus } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(private cacheManager: Cache) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const key = context.switchToHttp().getRequest().url;
const cached = await this.cacheManager.get(key);
if (cached) return of(cached); // handler never runs
return next.handle().pipe(
tap(async (data) => {
await this.cacheManager.set(key, data, 300); // 5 min TTL
}),
catchError(() => {
throw new HttpException('Service unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}),
);
}
}When of(cached) returns, the route handler is never invoked. On a miss the handler runs, stores the result, and returns it. One edge case worth knowing: concurrent requests arriving before the cache is populated all hit the DB simultaneously. That is the cache stampede problem and solving it requires a separate lock on top of this pattern.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.