Skip to content

Fix: NestJS Interceptor Not Triggered — Interceptors Not Running

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix NestJS interceptors not being called — global vs controller vs method binding, response transformation, async interceptors, execution context, and interceptor ordering.

The Problem

A NestJS interceptor is registered but never executes:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Interceptor called');  // This never prints
    return next.handle();
  }
}

// Registered globally in main.ts:
app.useGlobalInterceptors(new LoggingInterceptor());
// But the log never appears

Or the interceptor runs but the response transformation doesn’t apply:

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => ({ success: true, data })),  // Response should be wrapped
    );
  }
}
// Response is still the raw data — not wrapped in { success: true, data: ... }

Or the interceptor runs for REST routes but not for GraphQL or WebSocket:

// Interceptor works on GET /users but not on the GraphQL resolver
@Query(() => [User])
async users() {
  return this.usersService.findAll();
}
// Interceptor not triggered for GraphQL queries

Why This Happens

NestJS interceptors sit in the request/response pipeline, but several configuration issues prevent them from running:

  • Binding level mismatchuseGlobalInterceptors() in main.ts doesn’t have access to the DI container (for injecting services). Use APP_INTERCEPTOR provider instead for interceptors that need injection.
  • Exception thrown before interceptor — if a Guard rejects the request, interceptors after it in the pipeline don’t run. Guards run before interceptors.
  • @UseInterceptors() applied to wrong level — a method-level decorator doesn’t affect other methods in the controller, and a class-level decorator doesn’t affect parent class methods.
  • Not handling the Observable correctlyintercept() must return an Observable. If you return a Promise or a plain value, NestJS may not process the response correctly.
  • GraphQL/WebSocket context — interceptors for HTTP don’t automatically apply to GraphQL resolvers or WebSocket gateways without context switching.
  • Execution order — multiple interceptors run in registration order. If one interceptor throws or short-circuits, later interceptors don’t run.

The NestJS request pipeline runs in a strict order: Middleware -> Guards -> Interceptors (before) -> Pipes -> Route Handler -> Interceptors (after) -> Exception Filters. Understanding this order is critical because a failure at any earlier stage prevents later stages from executing. If your interceptor never runs, the request is being rejected before it reaches the interceptor stage — most commonly by a Guard returning false or throwing an UnauthorizedException.

A less obvious cause: when you register an interceptor globally via app.useGlobalInterceptors(new MyInterceptor()), you’re creating the interceptor outside the NestJS dependency injection container. The interceptor works fine as long as it has no constructor dependencies. The moment you add constructor(private readonly logger: Logger), the dependency is undefined at runtime — not an error that crashes the app, but the interceptor silently misbehaves because this.logger is undefined.

Diagnostic Timeline

When your interceptor is registered but not firing, walk through the NestJS pipeline from front to back.

Minute 0 — Confirm the request reaches the server. Add a temporary console.log directly in the route handler:

@Get('users')
getUsers() {
  console.log('Route handler reached');  // Does this print?
  return this.usersService.findAll();
}

If the handler doesn’t print, the request is being blocked before the route — check middleware, guards, or the route path itself. If it prints, the request does reach the handler, and the issue is with how the interceptor is registered or implemented.

Minute 1 — Check the pipeline order. Add temporary logging to each layer to see what fires:

// Middleware
export class LogMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log('[Middleware] fired');
    next();
  }
}

// Guard
@Injectable()
export class LogGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    console.log('[Guard] fired');
    return true;
  }
}

// Interceptor
@Injectable()
export class LogInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    console.log('[Interceptor] fired');
    return next.handle();
  }
}

If [Middleware] and [Guard] print but [Interceptor] does not, a guard between them is blocking. If only [Middleware] prints, the guard is rejecting the request.

Minute 2 — Check for a guard that short-circuits. If you use @UseGuards(AuthGuard) on the controller or globally, the guard’s canActivate runs before any interceptor. If canActivate returns false or throws, the interceptor never runs. Temporarily remove the guard to confirm.

Minute 3 — Check the registration method. If you used app.useGlobalInterceptors(new MyInterceptor()) in main.ts, verify the interceptor class has no constructor parameters. If it does, switch to APP_INTERCEPTOR:

// Check: does the interceptor constructor need injected dependencies?
@Injectable()
export class MyInterceptor implements NestInterceptor {
  constructor(private readonly logger: Logger) {}  // Needs DI
  // ...
}
// If yes, useGlobalInterceptors(new MyInterceptor()) silently breaks.
// Switch to APP_INTERCEPTOR in a module.

Minute 4 — Check scope alignment. If you used @UseInterceptors(MyInterceptor) on a specific controller, verify the request is actually hitting that controller and not a different one with the same route prefix. NestJS routes can overlap. Add console.log(context.getClass().name) inside the interceptor to confirm which controller the request matched.

Minute 5 — Verify the Observable chain is correct. If the interceptor runs (you see the “before” log) but the response transformation doesn’t apply, check that intercept() returns the Observable from next.handle().pipe(...) — not just next.handle() without the pipe, and not a new Observable that ignores the handler’s response.

Fix 1: Register Global Interceptors with APP_INTERCEPTOR

useGlobalInterceptors() in main.ts creates the interceptor outside the DI container — it can’t inject services. Use APP_INTERCEPTOR for interceptors that need dependencies:

// WRONG — can't inject services, runs outside DI
// main.ts
app.useGlobalInterceptors(new LoggingInterceptor());
// If LoggingInterceptor needs LogService injected, this fails

// CORRECT — use APP_INTERCEPTOR in a module
// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from './interceptors/logging.interceptor';

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

// Now LoggingInterceptor can inject services:
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private readonly logger: Logger) {}  // DI works with APP_INTERCEPTOR

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    this.logger.log('Request received');
    return next.handle();
  }
}

useGlobalInterceptors() is still valid for simple interceptors with no dependencies:

// main.ts — simple interceptors without DI
app.useGlobalInterceptors(
  new TimeoutInterceptor(),
  new TransformInterceptor(),
);

Fix 2: Implement the Interceptor Correctly

The intercept method must return an Observable. Missing the return or mishandling the Observable chain causes the interceptor to silently fail:

// WRONG — missing return of next.handle()
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');
    next.handle();  // Not returned! Request hangs — no response sent
  }
}

// WRONG — returns a Promise instead of Observable
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  async intercept(context: ExecutionContext, next: CallHandler): Promise<any> {
    //     ^^^^ async makes this return a Promise — breaks Observable pipeline
    console.log('Before...');
    return next.handle();  // next.handle() returns Observable, wrapped in Promise
  }
}
// CORRECT — return Observable from next.handle()
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap, map, catchError } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const start = Date.now();
    const req = context.switchToHttp().getRequest();

    console.log(`→ ${req.method} ${req.url}`);

    return next.handle().pipe(
      tap(() => {
        console.log(`← ${req.method} ${req.url} (${Date.now() - start}ms)`);
      }),
    );
  }
}

Response transformation interceptor:

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { success: boolean; data: T }> {
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<{ success: boolean; data: T }> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

Fix 3: Apply Interceptors at the Right Level

Interceptors can be applied globally, per-controller, or per-method:

// Method level — only this endpoint
@Get('users')
@UseInterceptors(LoggingInterceptor)
getUsers() {
  return this.usersService.findAll();
}

// Controller level — all endpoints in this controller
@Controller('users')
@UseInterceptors(LoggingInterceptor, CacheInterceptor)
export class UsersController {
  @Get()
  findAll() { ... }  // Both interceptors run

  @Get(':id')
  findOne() { ... }  // Both interceptors run
}

// Global — all routes
// In main.ts:
app.useGlobalInterceptors(new LoggingInterceptor());
// Or in AppModule with APP_INTERCEPTOR (preferred for DI)

@UseInterceptors() with class vs instance:

// Pass the class — NestJS instantiates it (uses DI)
@UseInterceptors(LoggingInterceptor)

// Pass an instance — no DI, you manage it manually
@UseInterceptors(new LoggingInterceptor())

For interceptors that need injected services, always pass the class (not an instance) with @UseInterceptors() or use APP_INTERCEPTOR.

Fix 4: Handle Async Operations in Interceptors

If you need async operations before or after the route handler:

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

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(private readonly auditService: AuditService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();

    // Async operation BEFORE the route handler — use from() to convert Promise to Observable
    return from(this.auditService.logRequest(req)).pipe(
      switchMap(() => next.handle()),  // Then call the route handler
      mergeMap(async (data) => {
        // Async operation AFTER the route handler
        await this.auditService.logResponse(req, data);
        return data;  // Return the original response
      }),
    );
  }
}

Simpler async pattern with from():

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
  return next.handle().pipe(
    // switchMap converts the sync response to an async one
    switchMap(async (data) => {
      const enriched = await this.enrichData(data);
      return enriched;
    }),
  );
}

Fix 5: Handle Exceptions in Interceptors

Interceptors can catch exceptions thrown by route handlers and transform them:

import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError((error) => {
        // Transform or log errors
        console.error('Error caught by interceptor:', error);

        // Re-throw as a different exception
        if (error.code === 'ECONNREFUSED') {
          return throwError(() => new ServiceUnavailableException('Database unavailable'));
        }

        // Re-throw the original error
        return throwError(() => error);
      }),
    );
  }
}

Note: Exception Filters run AFTER interceptors for exceptions. If an interceptor’s catchError doesn’t handle the error, it propagates to the Exception Filter. The execution order is: Middleware -> Guards -> Interceptors (before) -> Route Handler -> Interceptors (after) -> Exception Filters.

Fix 6: Adapt Interceptors for GraphQL Context

HTTP interceptors use context.switchToHttp(). For GraphQL, switch to the GraphQL context:

@Injectable()
export class GraphQLInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const contextType = context.getType<'http' | 'graphql' | 'ws'>();

    if (contextType === 'graphql') {
      const gqlContext = GqlExecutionContext.create(context);
      const { req } = gqlContext.getContext();

      console.log('GraphQL operation:', gqlContext.getInfo().fieldName);
    } else if (contextType === 'http') {
      const req = context.switchToHttp().getRequest();
      console.log('HTTP request:', req.url);
    }

    return next.handle();
  }
}

Apply to GraphQL resolver:

@Resolver(() => User)
@UseInterceptors(LoggingInterceptor)  // Applies to all resolvers in this class
export class UsersResolver {
  @Query(() => [User])
  @UseInterceptors(CacheInterceptor)  // Applies only to this query
  async users() {
    return this.usersService.findAll();
  }
}

Fix 7: Debug Interceptor Execution Order

Multiple interceptors run in registration order (outermost first, innermost last for “before”; reverse order for “after”):

// Registration order matters
providers: [
  { provide: APP_INTERCEPTOR, useClass: FirstInterceptor },   // Outermost
  { provide: APP_INTERCEPTOR, useClass: SecondInterceptor },  // Middle
  { provide: APP_INTERCEPTOR, useClass: ThirdInterceptor },   // Innermost
]

// Execution order:
// Request: First → Second → Third → Route Handler
// Response: Third → Second → First

Add temporary logging to debug:

@Injectable()
export class DebugInterceptor implements NestInterceptor {
  constructor(private readonly name: string) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log(`[${this.name}] before`);
    return next.handle().pipe(
      tap({
        next: (data) => console.log(`[${this.name}] after success:`, data),
        error: (err) => console.log(`[${this.name}] after error:`, err.message),
        complete: () => console.log(`[${this.name}] complete`),
      }),
    );
  }
}

// Use with explicit name for debugging
app.useGlobalInterceptors(
  new DebugInterceptor('Interceptor1'),
  new DebugInterceptor('Interceptor2'),
);

Check if a Guard is blocking before the interceptor:

// Guards run before interceptors — if a guard rejects, interceptors don't run
@Controller('admin')
@UseGuards(JwtAuthGuard)      // Runs first
@UseInterceptors(LoggingInterceptor)  // Only runs if guard passes
export class AdminController { ... }

If LoggingInterceptor never logs, check whether JwtAuthGuard is rejecting the request.

Still Not Working?

Verify intercept() is implemented — if the class doesn’t implement NestInterceptor properly, NestJS may silently skip it:

// Wrong — missing @Injectable() or wrong method name
export class MyInterceptor {
  // Wrong: method named 'handle' instead of 'intercept'
  handle(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

// Correct
@Injectable()
export class MyInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle();
  }
}

Global interceptors from main.ts run after module interceptors — if you register an interceptor in main.ts AND via APP_INTERCEPTOR, the APP_INTERCEPTOR runs first.

Microservice context — in NestJS microservices, interceptors use context.switchToRpc(). HTTP interceptors don’t apply to microservice message handlers without context switching.

Interceptor skipped for SSE (Server-Sent Events) endpoints — routes decorated with @Sse() return an Observable directly, bypassing the normal response pipeline. Interceptors that transform the response via next.handle().pipe(map(...)) don’t work as expected because the response is streamed, not returned as a single value. For SSE endpoints, handle transformations inside the Observable returned by the route handler itself.

Fastify adapter differences — if you use NestJS with Fastify instead of Express, context.switchToHttp().getRequest() returns a Fastify FastifyRequest, not an Express Request. Properties like req.body, req.params, and req.query work the same, but req.ip, req.hostname, and middleware chaining behave differently. If your interceptor works with Express but not Fastify, check property access patterns.

Interceptor runs twice per request — this usually happens when you register the interceptor both via APP_INTERCEPTOR in a module and via @UseInterceptors() on a controller/method. The module-level interceptor applies globally, and the decorator adds it again. Use one method, not both.

For related NestJS issues, see Fix: NestJS Guard Not Working, Fix: NestJS Circular Dependency, Fix: NestJS Module Not Found, and Fix: Jest Mock Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles