Lesson 18 of 5024 min read

NestJS Interceptors Tutorial – Logging, Transform and Caching

Learn how NestJS interceptors work using RxJS, and how to build logging, response transformation, and caching interceptors with real examples.

Author: CodersNexus

NestJS Interceptors Tutorial – Logging, Transform and Caching

Interceptors are arguably the most powerful and flexible building block in NestJS's request-handling pipeline, precisely because they can act both before and after a route handler executes. This dual-sided capability makes them perfect for cross-cutting concerns like logging execution time, transforming response shapes consistently, and even caching results entirely, skipping the route handler on repeated requests.

This lesson explains how interceptors work using RxJS observables under the hood, and walks through three of the most practical, real-world interceptor patterns: logging, response transformation, and caching.

NestJS Interceptors: Learning Objectives

  • Understand what interceptors are and how they differ from guards and pipes.
  • Explain the role of RxJS observables in NestJS's interceptor implementation.
  • Build a logging interceptor that measures route handler execution time.
  • Build a response transformation interceptor that wraps every response in a consistent shape.
  • Understand the concept of a caching interceptor that can bypass the route handler entirely.

NestJS Interceptors: Key Terms and Definitions

  • Interceptor: A class implementing the NestInterceptor interface, capable of running logic both before and after a route handler executes.
  • NestInterceptor interface: The contract requiring an intercept(context, next) method that returns an Observable.
  • CallHandler: An object passed to an interceptor's intercept() method, whose handle() method invokes the actual route handler and returns an Observable of its result.
  • RxJS Observable: A stream-based data structure representing a value (or sequence of values) that will be delivered over time, used by NestJS to represent the eventual response from a route handler.
  • pipe() operator: An RxJS method used to chain transformation operators, such as map() or tap(), onto an Observable's emitted values.

How NestJS Interceptors Works: Detailed Explanation

An interceptor in NestJS is a class implementing the NestInterceptor interface, requiring a single method: intercept(context: ExecutionContext, next: CallHandler). This method must return an RxJS Observable, and it is given a CallHandler object whose handle() method, when called, actually triggers execution of the underlying route handler and returns an Observable representing its eventual result.

This structure is what gives interceptors their unique power: because you receive control both before calling next.handle() and can chain additional logic onto the returned Observable using RxJS operators, you can execute code both before and after the route handler runs, wrapping it much like an onion layer. This is fundamentally different from guards, which only make a single before-the-fact yes/no decision, and pipes, which only transform input data going into the handler.

A classic logging interceptor demonstrates this dual-sided nature clearly. Before calling next.handle(), it records the current timestamp. It then calls next.handle(), receiving an Observable, and chains an RxJS tap() operator onto it, which lets you run a side effect (like logging) once the route handler's result actually arrives, at which point you can calculate and log the total execution duration, all without modifying the actual response data itself.

A response transformation interceptor works similarly but uses RxJS's map() operator instead of tap(), actually modifying the value emitted by the Observable before it reaches the client. This is an extremely common pattern for ensuring every successful API response follows a consistent shape, for example wrapping whatever a route handler returns inside a standard envelope like { success: true, data: <original result>, timestamp: <current time> }, without needing to manually apply this wrapping inside every single controller method.

A caching interceptor showcases the most powerful capability interceptors have: the ability to skip calling next.handle() entirely. If an interceptor determines that a valid cached response already exists for the current request (perhaps checking a cache store using the request's URL as a key), it can simply return an Observable containing that cached value directly, using RxJS's of() function, completely bypassing the actual route handler and any expensive computation or database queries it would have performed.

Interview-Friendly Explanation

A strong interview or viva answer for nestjs interceptors must go beyond a one-line definition. Interviewers evaluating BCA, MCA, B.Tech, and self-taught backend developers reward answers that combine a precise definition, a clear working explanation, a real-world example, and a conclusion that highlights why it matters in production Node.js applications. Use the four-point framework below when answering under time pressure.

  • Definition point: Interceptor: A class implementing the NestInterceptor interface, capable of running logic both before and after a route handler executes.
  • Working point: Explain the role of RxJS observables in NestJS's interceptor implementation.
  • Example point: Nearly every production NestJS API implements some version of a response transformation interceptor to guarantee a single, consistent success response shape across every single endpoint.
  • Conclusion point: Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.

How to Answer This in a Technical Interview

  1. Give a two-to-three sentence definition using correct NestJS and Node.js terminology.
  2. Add one specific example drawn from a real backend scenario such as an e-commerce, fintech, or SaaS API.
  3. Mention the request lifecycle stage this concept operates at (pipe, guard, middleware, filter, interceptor) since interviewers often test whether you know the execution order.
  4. Close with one benefit, trade-off, or production use case.
  5. Avoid vague answers like "it just validates stuff" — interviewers filter these out immediately.

Practical Scenario

Imagine you are hardening a production API for a SaaS product, similar to systems used at companies like Razorpay, Freshworks, or Postman. Every lesson in this module maps directly to a decision you will make while making that backend secure, predictable, and resilient to bad input: validating what comes in, transforming it safely, catching failures gracefully, and protecting routes from unauthorized access.

NestJS Interceptors: Architecture and Flow Diagram

Visualize an interceptor wrapping around the route handler like an onion layer:

[Interceptor: code BEFORE next.handle()] --> [next.handle() invokes the Route Handler] --> [Interceptor: code AFTER, via RxJS operators like tap() or map()] --> [Final Response sent to client]

For caching, add: '[Interceptor checks cache] --> Cache Hit? --> Yes: return cached Observable directly, skipping the handler | No: call next.handle() and store the result'

NestJS Interceptors: Interceptor Use Case Reference Table

Interceptor Use CaseKey RxJS OperatorWhat It Achieves
Logging execution timetap()Runs a side effect (logging) without altering the response
Transforming response shapemap()Modifies the actual value returned to the client
Caching resultsof() (bypassing next.handle())Returns a cached value directly, skipping the route handler
Error handling/retrycatchError()Intercepts errors emitted by the route handler's Observable

NestJS Interceptors: NestJS Code Example

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

// 1. Logging interceptor — measures execution time
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const start = Date.now();

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

// 2. Response transformation interceptor — wraps every response consistently
@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => ({
        success: true,
        timestamp: new Date().toISOString(),
        data,
      })),
    );
  }
}

// 3. Simplified caching interceptor — bypasses the handler on a cache hit
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, any>();

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

    if (this.cache.has(key)) {
      return of(this.cache.get(key)); // returns cached value, skips the route handler
    }

    return next.handle().pipe(
      tap((response) => this.cache.set(key, response)),
    );
  }
}

// Applying interceptors to a controller
import { Controller, Get, UseInterceptors } from '@nestjs/common';

@Controller('products')
@UseInterceptors(LoggingInterceptor, TransformInterceptor)
export class ProductsController {
  @Get()
  findAll() {
    return [{ id: 1, name: 'Laptop' }, { id: 2, name: 'Phone' }];
  }
}

LoggingInterceptor records a start timestamp before calling next.handle(), then uses RxJS's tap() operator to log the total execution duration once the route handler's result arrives, without altering the response data itself. TransformInterceptor uses map() instead, actually reshaping the emitted value into a consistent { success, timestamp, data } envelope around whatever the route handler originally returned. CacheInterceptor demonstrates the most dramatic capability: checking an in-memory cache first, and if a match exists, returning it immediately via of(), completely bypassing next.handle() and therefore the actual route handler logic, while a cache miss falls through to calling the handler normally and storing its result for next time. Applying LoggingInterceptor and TransformInterceptor together on ProductsController shows how multiple interceptors compose, each wrapping progressively around the route handler's execution.

Real-World NestJS Interceptors Industry Examples

  • Nearly every production NestJS API implements some version of a response transformation interceptor to guarantee a single, consistent success response shape across every single endpoint.
  • Performance monitoring systems commonly use logging interceptors, similar to the example shown, feeding execution time data into observability tools like Datadog or New Relic.
  • High-traffic read-heavy endpoints, such as product catalogs or public content feeds, frequently use caching interceptors (often backed by Redis rather than an in-memory Map) to dramatically reduce database load.
  • API versioning and backward-compatibility layers sometimes use interceptors to transform newer internal data shapes into an older response format still expected by legacy client applications.

NestJS Interceptors Interview Questions and Answers

Q1. What makes interceptors different from guards and pipes in NestJS?

Short answer: Guards make a single before-the-fact decision about whether a request can proceed, and pipes transform incoming data before it reaches a handler. Interceptors are unique because they can run logic both before and after a route handler executes, since they receive a CallHandler whose handle() method returns an Observable they can further transform or even bypass entirely.

Detailed explanation: An interceptor in NestJS is a class implementing the NestInterceptor interface, requiring a single method: intercept(context: ExecutionContext, next: CallHandler). This method must return an RxJS Observable, and it is given a CallHandler object whose handle() method, when called, actually triggers execution of the underlying route handler and returns an Observable representing its eventual result. This structure is what gives interceptors their unique power: because you receive control both before calling next.handle() and can chain additional logic onto the returned Observable using RxJS operators, you can execute code both before and after the route handler runs, wrapping it much like an onion layer. This is fundamentally different from guards, which only make a single before-the-fact yes/no decision, and pipes, which only transform input data going into the handler. A classic logging interceptor demonstrates this dual-sided nature clearly. Before calling next.handle(), it records the current timestamp. It then calls next.handle(), receiving an Observable, and chains an RxJS tap() operator onto it, which lets you run a side effect (like logging) once the route handler's result actually arrives, at which point you can calculate and log the total execution duration, all without modifying the actual response data itself. A response transformation interceptor works similarly but uses RxJS's map() operator instead of tap(), actually modifying the value emitted by the Observable before it reaches the client. This is an extremely common pattern for ensuring every successful API response follows a consistent shape, for example wrapping whatever a route handler returns inside a standard envelope like { success: true, data: <original result>, timestamp: <current time> }, without needing to manually apply this wrapping inside every single controller method. A caching interceptor showcases the most powerful capability interceptors have: the ability to skip calling next.handle() entirely. If an interceptor determines that a valid cached response already exists for the current request (perhaps checking a cache store using the request's URL as a key), it can simply return an Observable containing that cached value directly, using RxJS's of() function, completely bypassing the actual route handler and any expensive computation or database queries it would have performed.

Practical example: Nearly every production NestJS API implements some version of a response transformation interceptor to guarantee a single, consistent success response shape across every single endpoint.

Interview tip: Interceptors implement NestInterceptor with an intercept(context, next) method returning an RxJS Observable.

Revision hook: Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.

Q2. Why do NestJS interceptors return an RxJS Observable instead of a plain value?

Short answer: Using an Observable allows NestJS to represent the asynchronous, eventual result of a route handler in a way that supports powerful composition through RxJS operators like map(), tap(), and catchError(), enabling interceptors to react to, transform, or even replace the handler's result in a clean, declarative style.

Detailed explanation: LoggingInterceptor records a start timestamp before calling next.handle(), then uses RxJS's tap() operator to log the total execution duration once the route handler's result arrives, without altering the response data itself. TransformInterceptor uses map() instead, actually reshaping the emitted value into a consistent { success, timestamp, data } envelope around whatever the route handler originally returned. CacheInterceptor demonstrates the most dramatic capability: checking an in-memory cache first, and if a match exists, returning it immediately via of(), completely bypassing next.handle() and therefore the actual route handler logic, while a cache miss falls through to calling the handler normally and storing its result for next time. Applying LoggingInterceptor and TransformInterceptor together on ProductsController shows how multiple interceptors compose, each wrapping progressively around the route handler's execution.

Practical example: Performance monitoring systems commonly use logging interceptors, similar to the example shown, feeding execution time data into observability tools like Datadog or New Relic.

Interview tip: next.handle() actually invokes the route handler and returns its result as an Observable.

Revision hook: Understanding a handful of core RxJS operators (map, tap, catchError) unlocks the vast majority of real-world interceptor use cases.

Q3. How would you build an interceptor that ensures every API response follows the same consistent shape?

Short answer: You would implement NestInterceptor's intercept() method, call next.handle() to get the Observable representing the route handler's result, and chain an RxJS map() operator onto it that wraps the original data inside a standard envelope object, such as { success: true, data: originalResult }, before it is sent to the client.

Detailed explanation: Interceptors, implementing the NestInterceptor interface, are the most flexible building block in NestJS's request-handling pipeline because they can execute logic both before and after a route handler runs, thanks to RxJS Observables returned by the CallHandler's handle() method. A logging interceptor uses RxJS's tap() operator to measure and log execution time without altering the response. A response transformation interceptor uses map() to reshape every successful response into a consistent envelope. A caching interceptor demonstrates the most powerful capability of all: bypassing next.handle() entirely and returning a cached Observable directly when a valid cached value already exists, skipping the route handler's execution altogether. Mastering interceptors, and the small set of RxJS operators they commonly rely on, unlocks logging, response standardization, and performance optimization patterns used throughout real production NestJS applications.

Practical example: High-traffic read-heavy endpoints, such as product catalogs or public content feeds, frequently use caching interceptors (often backed by Redis rather than an in-memory Map) to dramatically reduce database load.

Interview tip: tap() runs side effects (logging) without altering data; map() transforms/reshapes the actual response data.

Revision hook: Response transformation interceptors are one of the most commonly adopted patterns for keeping API responses consistent across an entire application.

Q4. How can an interceptor implement caching, and what makes this possible?

Short answer: An interceptor can check a cache store using a key like the request URL before calling next.handle(). If a valid cached value exists, the interceptor can return an Observable containing that cached value directly (using RxJS's of() function), completely skipping the call to next.handle() and therefore avoiding execution of the actual route handler and any expensive work it would perform.

Detailed explanation: An interceptor in NestJS is a class implementing the NestInterceptor interface, requiring a single method: intercept(context: ExecutionContext, next: CallHandler). This method must return an RxJS Observable, and it is given a CallHandler object whose handle() method, when called, actually triggers execution of the underlying route handler and returns an Observable representing its eventual result. This structure is what gives interceptors their unique power: because you receive control both before calling next.handle() and can chain additional logic onto the returned Observable using RxJS operators, you can execute code both before and after the route handler runs, wrapping it much like an onion layer. This is fundamentally different from guards, which only make a single before-the-fact yes/no decision, and pipes, which only transform input data going into the handler. A classic logging interceptor demonstrates this dual-sided nature clearly. Before calling next.handle(), it records the current timestamp. It then calls next.handle(), receiving an Observable, and chains an RxJS tap() operator onto it, which lets you run a side effect (like logging) once the route handler's result actually arrives, at which point you can calculate and log the total execution duration, all without modifying the actual response data itself. A response transformation interceptor works similarly but uses RxJS's map() operator instead of tap(), actually modifying the value emitted by the Observable before it reaches the client. This is an extremely common pattern for ensuring every successful API response follows a consistent shape, for example wrapping whatever a route handler returns inside a standard envelope like { success: true, data: <original result>, timestamp: <current time> }, without needing to manually apply this wrapping inside every single controller method. A caching interceptor showcases the most powerful capability interceptors have: the ability to skip calling next.handle() entirely. If an interceptor determines that a valid cached response already exists for the current request (perhaps checking a cache store using the request's URL as a key), it can simply return an Observable containing that cached value directly, using RxJS's of() function, completely bypassing the actual route handler and any expensive computation or database queries it would have performed.

Practical example: API versioning and backward-compatibility layers sometimes use interceptors to transform newer internal data shapes into an older response format still expected by legacy client applications.

Interview tip: Interceptors can bypass next.handle() entirely (e.g., for caching) by returning their own Observable instead.

Revision hook: The ability to bypass next.handle() entirely makes interceptors uniquely suited for caching, unlike any other NestJS request-pipeline component.

NestJS Interceptors MCQs and Practice Questions

1. Which interface must a class implement to function as a NestJS interceptor?

  1. CanActivate
  2. PipeTransform
  3. NestInterceptor
  4. NestMiddleware

Answer: C. NestInterceptor

Explanation: A NestJS interceptor must implement the NestInterceptor interface, providing an intercept(context, next) method that returns an RxJS Observable.

Concept link: Interceptors implement NestInterceptor with an intercept(context, next) method returning an RxJS Observable.

Why this matters: Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.

2. Which RxJS operator would you use in an interceptor to log something without changing the response data?

  1. map()
  2. tap()
  3. filter()
  4. reduce()

Answer: B. tap()

Explanation: tap() allows you to run a side effect, such as logging, whenever a value is emitted by an Observable, without modifying the emitted value itself, making it ideal for logging interceptors.

Concept link: next.handle() actually invokes the route handler and returns its result as an Observable.

Why this matters: Understanding a handful of core RxJS operators (map, tap, catchError) unlocks the vast majority of real-world interceptor use cases.

3. Which RxJS operator would you use in an interceptor to actually reshape the response returned to the client?

  1. tap()
  2. map()
  3. delay()
  4. toPromise()

Answer: B. map()

Explanation: map() transforms the value emitted by an Observable into a new value, making it the correct operator for interceptors that need to modify or wrap the actual data returned to the client.

Concept link: tap() runs side effects (logging) without altering data; map() transforms/reshapes the actual response data.

Why this matters: Response transformation interceptors are one of the most commonly adopted patterns for keeping API responses consistent across an entire application.

4. What must an interceptor do to completely skip execution of the actual route handler?

  1. Call next.handle() twice
  2. Return an Observable directly (e.g. using of()) without calling next.handle()
  3. Throw an exception
  4. Set a header on the response

Answer: B. Return an Observable directly (e.g. using of()) without calling next.handle()

Explanation: By returning an Observable of a value directly, such as through RxJS's of() function, an interceptor can bypass calling next.handle() entirely, which is exactly how a caching interceptor serves a cached response without ever executing the actual route handler logic.

Concept link: Interceptors can bypass next.handle() entirely (e.g., for caching) by returning their own Observable instead.

Why this matters: The ability to bypass next.handle() entirely makes interceptors uniquely suited for caching, unlike any other NestJS request-pipeline component.

Common NestJS Interceptors Mistakes to Avoid

  • Forgetting to call next.handle() at all in an interceptor (when not intentionally bypassing it), which means the route handler never actually executes and the request hangs or errors.
  • Using map() when tap() was intended, accidentally replacing the actual response data with the return value of a logging function instead of just running a side effect.
  • Building an in-memory cache interceptor (like the simplified example) for a production application without considering that a real distributed system needs a shared cache store like Redis, not per-instance memory.
  • Applying a response-transforming interceptor globally without considering that it will also wrap error responses or already-transformed data from other interceptors, causing double-wrapping.

NestJS Interceptors: Interview Notes and Exam Tips

  • Interceptors implement NestInterceptor with an intercept(context, next) method returning an RxJS Observable.
  • next.handle() actually invokes the route handler and returns its result as an Observable.
  • tap() runs side effects (logging) without altering data; map() transforms/reshapes the actual response data.
  • Interceptors can bypass next.handle() entirely (e.g., for caching) by returning their own Observable instead.

Key NestJS Interceptors Takeaways

  • Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.
  • Understanding a handful of core RxJS operators (map, tap, catchError) unlocks the vast majority of real-world interceptor use cases.
  • Response transformation interceptors are one of the most commonly adopted patterns for keeping API responses consistent across an entire application.
  • The ability to bypass next.handle() entirely makes interceptors uniquely suited for caching, unlike any other NestJS request-pipeline component.

NestJS Interceptors: Summary

Interceptors, implementing the NestInterceptor interface, are the most flexible building block in NestJS's request-handling pipeline because they can execute logic both before and after a route handler runs, thanks to RxJS Observables returned by the CallHandler's handle() method. A logging interceptor uses RxJS's tap() operator to measure and log execution time without altering the response. A response transformation interceptor uses map() to reshape every successful response into a consistent envelope. A caching interceptor demonstrates the most powerful capability of all: bypassing next.handle() entirely and returning a cached Observable directly when a valid cached value already exists, skipping the route handler's execution altogether. Mastering interceptors, and the small set of RxJS operators they commonly rely on, unlocks logging, response standardization, and performance optimization patterns used throughout real production NestJS applications.

Frequently Asked Questions

No, a solid understanding of a small handful of RxJS operators, primarily map(), tap(), and catchError(), covers the vast majority of practical interceptor use cases. Deeper RxJS knowledge becomes more relevant for advanced reactive programming patterns, but is not required to write effective, everyday interceptors. In interviews, tie this back to: Interceptors implement NestInterceptor with an intercept(context, next) method returning an RxJS Observable. In real applications, consider this example: Nearly every production NestJS API implements some version of a response transformation interceptor to guarantee a single, consistent success response shape across every single endpoint. Key revision takeaway: Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.

Pipes transform incoming request data before it reaches a route handler, operating only on the input side. Interceptors can transform the outgoing response after the route handler has already executed, operating on the output side, and can also run logic before the handler as well, making them suited for entirely different transformation needs. In interviews, tie this back to: next.handle() actually invokes the route handler and returns its result as an Observable. In real applications, consider this example: Performance monitoring systems commonly use logging interceptors, similar to the example shown, feeding execution time data into observability tools like Datadog or New Relic. Key revision takeaway: Understanding a handful of core RxJS operators (map, tap, catchError) unlocks the vast majority of real-world interceptor use cases.

Yes, multiple interceptors can be chained using @UseInterceptors(FirstInterceptor, SecondInterceptor), and they wrap around each other and the route handler in the order specified, meaning the 'before' logic of each runs in sequence, followed by the handler, followed by their 'after' logic typically in reverse order. In interviews, tie this back to: tap() runs side effects (logging) without altering data; map() transforms/reshapes the actual response data. In real applications, consider this example: High-traffic read-heavy endpoints, such as product catalogs or public content feeds, frequently use caching interceptors (often backed by Redis rather than an in-memory Map) to dramatically reduce database load. Key revision takeaway: Response transformation interceptors are one of the most commonly adopted patterns for keeping API responses consistent across an entire application.

No, the simplified in-memory Map-based example is meant purely for illustration. A genuinely production-ready caching interceptor would typically use a shared, distributed cache store like Redis, handle cache invalidation and expiration properly, and consider cache key uniqueness more carefully, especially in a multi-instance deployment. In interviews, tie this back to: Interceptors can bypass next.handle() entirely (e.g., for caching) by returning their own Observable instead. In real applications, consider this example: API versioning and backward-compatibility layers sometimes use interceptors to transform newer internal data shapes into an older response format still expected by legacy client applications. Key revision takeaway: The ability to bypass next.handle() entirely makes interceptors uniquely suited for caching, unlike any other NestJS request-pipeline component.

Yes, using RxJS's catchError() operator chained onto the Observable returned by next.handle(), an interceptor can intercept errors emitted during route handler execution, allowing for custom error transformation, logging, or fallback behavior, distinct from NestJS's dedicated exception filters. In interviews, tie this back to: Interceptors implement NestInterceptor with an intercept(context, next) method returning an RxJS Observable. In real applications, consider this example: Nearly every production NestJS API implements some version of a response transformation interceptor to guarantee a single, consistent success response shape across every single endpoint. Key revision takeaway: Interceptors are the only NestJS building block that can wrap logic on both sides of a route handler's execution.

Yes, NestJS includes a few built-in interceptors for common needs, such as ClassSerializerInterceptor, which automatically applies serialization rules (like excluding specific fields) defined on response DTOs using class-transformer decorators, without you needing to write this transformation logic manually. In interviews, tie this back to: next.handle() actually invokes the route handler and returns its result as an Observable. In real applications, consider this example: Performance monitoring systems commonly use logging interceptors, similar to the example shown, feeding execution time data into observability tools like Datadog or New Relic. Key revision takeaway: Understanding a handful of core RxJS operators (map, tap, catchError) unlocks the vast majority of real-world interceptor use cases.