Interceptors in Depth
Use the RxJS stream from next.handle() to reshape responses, log timings, cache results, and add timeouts.
Interceptors are the most versatile tool in the pipeline because they see both sides of the handler: they run code before it, and they own the RxJS stream of whatever it returns. Three patterns cover almost every real use.
1. Transform: a uniform response envelope
Wrap every payload in a consistent shape so clients always get { data, meta }. This is a map() over the response stream:
intercept(ctx, next) {
return next.handle().pipe(
map((data) => ({ data, timestamp: Date.now() })),
);
}2. Logging: measure the handler
Capture a start time before next.handle(), then use tap() to log once the stream completes. Because tap doesn't alter the value, it is the correct operator for side effects.
3. Cache: short-circuit the handler
An interceptor can decide not to call the handler at all. If a cached value exists, return of(cached) and the handler never runs. This is exactly how Nest's own CacheInterceptor works.
Bonus: timeouts and error mapping
Because you hold the stream, RxJS operators like timeout() and catchError() let you enforce a deadline or translate errors - all without touching the handler. Order matters: an interceptor bound earlier wraps the ones bound later, forming an onion around your route.
Example
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { map, tap, timeout, catchError } from 'rxjs/operators';
interface Envelope<T> {
data: T;
meta: { durationMs: number; cached: boolean };
}
@Injectable()
export class ResponseInterceptor<T>
implements NestInterceptor<T, Envelope<T>>
{
private readonly logger = new Logger('HTTP');
private readonly cache = new Map<string, T>();
intercept(
ctx: ExecutionContext,
next: CallHandler<T>,
): Observable<Envelope<T>> {
const req = ctx.switchToHttp().getRequest();
const key = `${req.method}:${req.url}`;
const start = Date.now();
// Cache hit: never touch the handler.
if (req.method === 'GET' && this.cache.has(key)) {
const data = this.cache.get(key) as T;
return of({ data, meta: { durationMs: 0, cached: true } });
}
return next.handle().pipe(
timeout(5000),
tap((data) => {
if (req.method === 'GET') this.cache.set(key, data);
this.logger.log(`${key} -> ${Date.now() - start}ms`);
}),
map((data) => ({
data,
meta: { durationMs: Date.now() - start, cached: false },
})),
catchError((err) => {
this.logger.error(`${key} failed: ${err.message}`);
throw err; // let exception filters format it
}),
);
}
}When to use it
- A developer implements a timeout interceptor using RxJS timeout() operator to cancel any handler that takes longer than 5 seconds and return a 408 error.
- A team caches GET responses in an interceptor by checking a Redis key before calling next.handle(), returning cached data without hitting the handler.
- An engineer uses a retry interceptor to automatically retry idempotent requests up to three times on transient HTTP 503 errors.
More examples
Timeout interceptor
Applies a 5-second timeout to every handler using RxJS timeout(), converting TimeoutError to a 408 RequestTimeoutException.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { timeout, catchError } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(5000),
catchError(err =>
err instanceof TimeoutError
? throwError(() => new RequestTimeoutException())
: throwError(() => err),
),
);
}
}In-memory cache interceptor
Returns a cached response immediately if the URL is already in the map, otherwise calls the handler and stores the result.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of, tap } from 'rxjs';
const cache = new Map<string, unknown>();
@Injectable()
export class CacheInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
const req = ctx.switchToHttp().getRequest();
const key = req.url;
if (cache.has(key)) return of(cache.get(key));
return next.handle().pipe(tap(data => cache.set(key, data)));
}
}Retry on transient errors
Wraps the handler observable with RxJS retry to automatically re-execute the handler up to three times with a 500 ms delay between attempts.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { retry, delay } from 'rxjs/operators';
@Injectable()
export class RetryInterceptor implements NestInterceptor {
intercept(_ctx: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
retry({ count: 3, delay: 500 }), // retry up to 3x with 500ms gap
);
}
}
Discussion