Skip to content
ExponaBLOG
NESTJS · DAY 030 / 100

Interceptors: logging, timing, transforms for production

Cross-cutting concerns wrapped around every handler.

Expona Admin7 min read

Interceptors: logging, timing, transforms

By day 30, you've built routes, services, and guards. Now it's time to wrap cross-cutting concerns around every handler without repeating yourself. Interceptors are middleware-like utilities that intercept requests before they reach your handler and responses before they leave your controller—they're where you measure performance, log activity, transform output, and cache intelligently without touching business logic.

What interceptors solve

Imagine you're running an e-commerce platform with dozens of endpoints. You need every response timed for monitoring, sensitive fields stripped from JSON, GET requests cached, and slow queries logged. Without interceptors, you'd scatter timing code, logging calls, and transformation logic across every handler. Interceptors centralize that concern once, then apply it globally or selectively.

In NestJS, an interceptor implements NestInterceptor, receives an ExecutionContext and a CallHandler, and returns an Observable. It sits in the request pipeline between the guard and the handler—perfect for cross-cutting behavior.

Timing interceptor—Measure every request using RxJS tap

Performance monitoring is non-negotiable in production. You need to know which endpoints are slow without adding a timer to every method. A timing interceptor wraps every request, captures the start time, and logs elapsed milliseconds once the handler completes.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  private readonly log = new Logger('Timing');

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const start = Date.now();
    const handler = ctx.getHandler();
    const name = handler.name || 'unnamed';

    return next.handle().pipe(
      tap(() => {
        const elapsed = Date.now() - start;
        this.log.log(`${name} took ${elapsed}ms`);
      }),
    );
  }
}

Register it globally in your main module or selectively on a controller:

typescript
import { APP_INTERCEPTOR } from '@nestjs/common';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TimingInterceptor,
    },
  ],
})
export class AppModule {}

Now every endpoint reports its execution time. If you're running CRM or trading systems where slow endpoints cascade into user complaints, this single interceptor gives you visibility into where time is actually spent—database queries, external API calls, heavy computation.

Timeouts—timeout(5000) in the pipe stops slow handlers from hanging requests

Timeouts prevent slow or stuck handlers from tying up your server threads indefinitely. In real platforms, a single unresponsive database query can starve your entire connection pool. A timeout interceptor uses RxJS timeout() to kill requests that exceed a threshold.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  RequestTimeoutException,
} from '@nestjs/common';
import { Observable, timeout, catchError } from 'rxjs';
import { throwError } from 'rxjs';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) => {
        if (err.name === 'TimeoutError') {
          return throwError(
            () => new RequestTimeoutException('Request exceeded 5 second limit'),
          );
        }
        return throwError(() => err);
      }),
    );
  }
}

Set this as a global interceptor or target specific slow-prone endpoints like file uploads or report generation. The timeout doesn't cancel the database query—that's on your database driver—but it releases the HTTP connection immediately, allowing your server to respond to other clients. In ride-hailing or travel platforms where request volume is high and backend latency varies, this interceptor is essential.

Response mapping—Transform results but avoid wrapping everything in envelopes by default

You might want to strip sensitive fields (passwords, tokens), wrap all responses in a consistent shape, or transform nested data. Interceptors let you do this without touching your handlers.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export class TransformInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        // Strip passwords from user objects
        if (data?.password) {
          const { password, ...rest } = data;
          return rest;
        }
        return data;
      }),
    );
  }
}

A tempting mistake: wrapping all responses in an envelope like { success: true, data: {...} }. If you do this globally, you'll bloat every response (including error responses your exception filter handles separately), and your OpenAPI docs become hard to maintain. Instead, apply transformation surgically: only when you actually need it.

Use mapping when you have genuinely cross-cutting concerns—filtering nested arrays, normalizing date formats, removing internal IDs. For endpoint-specific response shapes, keep that logic in the controller or a custom serializer.

Caching—CacheInterceptor caches GET responses with a single decorator

NestJS ships with a built-in CacheInterceptor that caches GET responses automatically. For e-commerce product listings, user profiles, or travel destination details that don't change every second, this is free performance.

First, set up the cache module:

typescript
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';

@Module({
  imports: [
    CacheModule.register({
      isGlobal: true,
      ttl: 60000, // 60 seconds
    }),
  ],
})
export class AppModule {}

Then apply the interceptor on controllers or routes:

typescript
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';

@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductsController {
  @Get(':id')
  async getProduct(@Param('id') id: string) {
    // This response is cached for 60 seconds by default
    return this.productService.findOne(id);
  }
}

The cache key is built from the route and query parameters. If a client requests GET /products/123 twice within 60 seconds, the second request returns the cached response instantly without hitting your database.

Fine-tune with @CacheTTL() and @CacheKey() decorators:

typescript
import { CacheTTL, CacheKey } from '@nestjs/cache-manager';

@Get('featured')
@CacheKey('featured-products')
@CacheTTL(3600) // 1 hour for featured list
async getFeaturedProducts() {
  return this.productService.findFeatured();
}

For invalidation, inject the cache service and evict manually when data changes:

typescript
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';

@Patch(':id')
async updateProduct(@Param('id') id: string, @Body() dto: UpdateProductDto) {
  const result = await this.productService.update(id, dto);
  await this.cacheManager.del(`products_${id}`);
  return result;
}

constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

Stacking interceptors in order

Multiple interceptors execute in the order they're registered. A common pattern in production platforms:

  1. Timing – Wraps everything, reports final elapsed time.
  2. Timeout – Kills requests over 5 seconds before they waste resources.
  3. Cache – Serves cached data instantly if available.
  4. Transform – Sanitizes the response.

Register them in main.ts or the module:

typescript
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TimingInterceptor,
      order: 1,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: TimeoutInterceptor,
      order: 2,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor,
      order: 3,
    },
  ],
})
export class AppModule {}

Common mistakes

  • Forgetting to unsubscribe in error paths. If your interceptor doesn't handle errors cleanly, it can swallow exceptions meant for your exception filter. Always use catchError or let the error flow through.
  • Applying caching to mutable endpoints. Caching a POST or PATCH response will serve stale data to the next client. Use CacheInterceptor only on GET routes or endpoints that explicitly declare @CacheTTL().
  • Blocking the entire pipe with heavy work. If your interceptor performs synchronous I/O or CPU-intensive operations before calling next.handle(), you'll increase latency for every request. Move heavy lifting into the Observable chain using mergeMap or switchMap.
  • Not considering query parameter variance. By default, CacheInterceptor includes query params in the cache key. A request to /products?sort=price won't match /products?sort=name. If that's unwanted, use @CacheKey() to set an explicit key.

Key takeaways

  • Interceptors centralize cross-cutting concerns—timing, caching, transformation—without duplicating logic in every handler.
  • Use tap for side effects (logging, metrics), map for transformations, and timeout to prevent hanging requests.
  • The built-in CacheInterceptor caches GET responses automatically; invalidate manually on mutations.
  • Stack interceptors in logical order: timing first (outermost), cache early, transform last.
  • Avoid wrapping all responses in envelopes by default—apply transformation selectively where it actually solves a problem.
  • Ensure error handling flows through your exception filter; don't swallow exceptions in the interceptor.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles