cyberangles guide

Angular Interceptors: Transforming HTTP Requests and Responses

Angular interceptors are middleware-like services that intercept HTTP requests and responses **globally** in your application. They act as a bridge between your app’s HTTP calls and the server, allowing you to modify requests before they’re sent, process responses before they reach your components, or handle errors uniformly. Instead of repeating logic like adding authentication headers or error handling in every service, interceptors let you define this logic once and apply it across all (or specific) HTTP requests. This promotes code reusability, reduces redundancy, and makes your codebase easier to maintain.

In modern web applications, handling HTTP communication is a core responsibility. Whether it’s fetching data from an API, sending user inputs to a server, or managing authentication tokens, repetitive HTTP-related tasks can clutter your codebase. Angular interceptors solve this problem by providing a centralized way to intercept, modify, and handle HTTP requests and responses.

In this blog, we’ll dive deep into Angular interceptors: what they are, how they work, common use cases with practical examples, advanced techniques, best practices, and troubleshooting tips. By the end, you’ll be able to leverage interceptors to streamline your app’s HTTP logic and write cleaner, more maintainable code.

Table of Contents

  1. Introduction to Angular Interceptors
  2. What Are Angular Interceptors?
  3. How Interceptors Work in Angular
  4. Setting Up Your First Interceptor
  5. Key Use Cases with Examples
    • Adding Headers (e.g., Authentication Tokens)
    • Error Handling
    • Request/Response Transformation
    • Logging
    • Caching
  6. Advanced Interceptor Techniques
  7. Best Practices
  8. Troubleshooting Common Issues
  9. Conclusion
  10. References

What Are Angular Interceptors?

Formally, an Angular interceptor is a class that implements the HttpInterceptor interface from @angular/common/http. This interface requires a single method: intercept, which takes two arguments:

  • req: HttpRequest<any>: The outgoing request object (immutable).
  • next: HttpHandler: A handler object that passes the request to the next interceptor in the chain (or to the server if it’s the last interceptor).

The intercept method must return an Observable<HttpEvent<any>>, which represents the server’s response (or an error).

How Interceptors Work in Angular

Angular processes HTTP requests through a chain of interceptors. Here’s a step-by-step breakdown of the flow:

  1. Request Initiation: A component or service calls HttpClient (e.g., http.get('api/data')).
  2. Interceptor Chain: The request passes through each interceptor in the order they’re provided. Each interceptor can:
    • Modify the request (e.g., add headers, transform the body).
    • Short-circuit the chain (e.g., return cached data instead of hitting the server).
    • Pass the request to the next interceptor via next.handle(modifiedReq).
  3. Server Communication: After passing through all interceptors, the final request is sent to the server.
  4. Response Handling: The server’s response flows back through the interceptor chain in reverse order. Each interceptor can modify the response (e.g., parse data, log metrics) or handle errors.
  5. Delivery to Subscriber: The processed response (or error) is delivered to the original subscriber (the component/service that initiated the request).

Angular Interceptor Flow
Image source: Angular Official Documentation

Setting Up Your First Interceptor

Let’s create a simple interceptor to add a X-App-Version header to all requests.

Step 1: Create the Interceptor Class

Generate a new interceptor using Angular CLI:

ng generate interceptor version

This creates version.interceptor.ts in your src/app directory. Open it and implement the intercept method:

// version.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable()
export class VersionInterceptor implements HttpInterceptor {

  constructor() {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Clone the request to modify it (since HttpRequest is immutable)
    const modifiedReq = request.clone({
      headers: request.headers.set('X-App-Version', '1.0.0')
    });

    // Pass the modified request to the next handler
    return next.handle(modifiedReq);
  }
}

Step 2: Provide the Interceptor

To register the interceptor, add it to the providers array of your root module (app.module.ts). Use the HTTP_INTERCEPTORS token from @angular/common/http:

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

import { AppComponent } from './app.component';
import { VersionInterceptor } from './version.interceptor';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: VersionInterceptor,
      multi: true // Allows multiple interceptors
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

The multi: true flag is critical—it tells Angular that HTTP_INTERCEPTORS is a multi-provider token, allowing multiple interceptors to be registered.

Key Use Cases with Examples

Let’s explore common scenarios where interceptors shine, with practical code examples.

1. Adding Headers (e.g., Authentication Tokens)

A frequent use case is adding headers like Authorization (for JWT tokens) or Content-Type to requests.

Example: Auth Token Interceptor
This interceptor adds a JWT token from localStorage to the Authorization header:

// auth.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Get token from localStorage
    const token = localStorage.getItem('authToken');

    if (token) {
      // Clone the request and add the token
      const authReq = request.clone({
        headers: request.headers.set('Authorization', `Bearer ${token}`)
      });
      return next.handle(authReq);
    }

    // If no token, pass the original request
    return next.handle(request);
  }
}

Register the Interceptor: Add it to app.module.ts providers:

providers: [
  { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
  { provide: HTTP_INTERCEPTORS, useClass: VersionInterceptor, multi: true } // Existing interceptor
]

2. Error Handling

Interceptors can centralize error handling for HTTP requests (e.g., 401 Unauthorized, 500 Server Error).

Example: Error Handling Interceptor
This interceptor catches errors, logs them, and redirects to the login page on 401 errors:

// error.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
import { ToastService } from './toast.service'; // Assume a service for showing toasts

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {

  constructor(private router: Router, private toast: ToastService) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        // Log error details
        console.error('HTTP Error:', error);

        // Handle specific error statuses
        switch (error.status) {
          case 401:
            // Unauthorized: Clear token and redirect to login
            localStorage.removeItem('authToken');
            this.router.navigate(['/login']);
            this.toast.show('Session expired. Please log in again.');
            break;
          case 404:
            this.toast.show('Resource not found.');
            break;
          case 500:
            this.toast.show('Server error. Please try again later.');
            break;
        }

        // Re-throw the error to let the subscriber handle it (optional)
        return throwError(() => new Error(error.message || 'Unknown error occurred'));
      })
    );
  }
}

3. Request/Response Transformation

Interceptors can modify request bodies (e.g., format data) or response data (e.g., parse nested JSON).

Example: Date Formatting Interceptor
This interceptor converts ISO date strings in the response to Date objects:

// date.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpResponse
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class DateInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      map((event: HttpEvent<any>) => {
        if (event instanceof HttpResponse) {
          // Transform response body
          this.convertDates(event.body);
        }
        return event;
      })
    );
  }

  private convertDates(body: any): void {
    if (body && typeof body === 'object') {
      for (const key in body) {
        if (body.hasOwnProperty(key)) {
          const value = body[key];
          // Check if value is an ISO date string
          if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
            body[key] = new Date(value); // Convert to Date object
          } else if (typeof value === 'object') {
            this.convertDates(value); // Recurse for nested objects
          }
        }
      }
    }
  }
}

4. Logging

Track request details (method, URL, duration) for debugging or analytics.

Example: Logging Interceptor

// logging.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    const startTime = Date.now();
    console.log(`Request: ${request.method} ${request.urlWithParams}`);

    return next.handle(request).pipe(
      tap({
        next: (event) => {
          const duration = Date.now() - startTime;
          console.log(`Response: ${request.method} ${request.urlWithParams} (${duration}ms)`);
        },
        error: (error) => {
          const duration = Date.now() - startTime;
          console.error(`Error: ${request.method} ${request.urlWithParams} (${duration}ms)`, error);
        }
      })
    );
  }
}

5. Caching

Reduce redundant requests by caching responses for a set duration.

Example: Caching Interceptor

// cache.interceptor.ts
import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpResponse
} from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';

interface CacheEntry {
  data: any;
  timestamp: number;
}

@Injectable()
export class CacheInterceptor implements HttpInterceptor {
  private cache = new Map<string, CacheEntry>();
  private cacheDuration = 5 * 60 * 1000; // 5 minutes

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Only cache GET requests
    if (request.method !== 'GET') {
      return next.handle(request);
    }

    const cacheKey = request.urlWithParams;
    const cachedEntry = this.cache.get(cacheKey);

    // Return cached data if it exists and is not expired
    if (cachedEntry && Date.now() - cachedEntry.timestamp < this.cacheDuration) {
      console.log(`Returning cached data for ${cacheKey}`);
      return of(new HttpResponse({ body: cachedEntry.data }));
    }

    // Fetch fresh data and cache it
    return next.handle(request).pipe(
      tap((event) => {
        if (event instanceof HttpResponse) {
          this.cache.set(cacheKey, {
            data: event.body,
            timestamp: Date.now()
          });
        }
      })
    );
  }
}

Advanced Interceptor Techniques

Multi-Interceptor Setup

You can register multiple interceptors (e.g., AuthInterceptorLoggingInterceptorErrorInterceptor). The order in the providers array determines the request flow (first provider runs first).

// app.module.ts providers
providers: [
 { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, // 1st
 { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, // 2nd
 { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true } // 3rd
]

Conditional Interception

Intercept only specific requests using request.url or request.headers:

// Only intercept requests to '/api'
if (request.url.startsWith('/api')) {
  // Modify request
}
return next.handle(request);

Async Operations in Interceptors

Use RxJS operators like mergeMap to handle async tasks (e.g., fetching a token from an async storage service):

import { mergeMap } from 'rxjs/operators';

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  return this.authService.getToken().pipe( // Assume getToken() returns Observable<string>
    mergeMap((token) => {
      const authReq = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
      return next.handle(authReq);
    })
  );
}

Best Practices

  1. Keep Interceptors Focused: Each interceptor should handle one responsibility (e.g., auth headers, error handling). Avoid “god interceptors” that do everything.

  2. Avoid Side Effects: Interceptors should be pure functions. Avoid modifying external state (e.g., localStorage) unless necessary.

  3. Provide Interceptors at the Root: Register interceptors in the root module (providers: [{ provide: HTTP_INTERCEPTORS, ... }]) to apply them app-wide. Avoid providing them in feature modules unless you want scoped behavior.

  4. Handle Async Correctly: Use RxJS operators like mergeMap, switchMap, or catchError for async logic—never use async/await directly in intercept.

  5. Test Interceptors: Write unit tests for interceptors using HttpClientTestingModule to verify they modify requests/responses as expected.

Troubleshooting Common Issues

Interceptors Not Running

  • Check Provider Setup: Ensure multi: true is set in the provider.
  • Order Matters: If an interceptor short-circuits the chain (e.g., returns cached data), subsequent interceptors won’t run.

Infinite Loops

  • Caused by modifying the request in a way that re-triggers the same interceptor (e.g., cloning a request with the same URL in a caching interceptor). Use conditional checks to avoid this.

Token Not Added to Headers

  • Ensure the token exists in storage when the interceptor runs. If using async storage, use mergeMap to wait for the token.

Conclusion

Angular interceptors are a powerful tool for centralizing HTTP logic. By leveraging them, you can simplify authentication, error handling, logging, and more—making your codebase cleaner and more maintainable.

Start small: implement an auth header interceptor, then add error handling or logging. As you become comfortable, explore advanced use cases like caching or request transformation. With interceptors, you’ll take full control of your app’s HTTP communication!

References