| |

Angular 36 🅰️ HTTP Interceptors

Every HTTP request an Angular application makes can pass through a series of functions that inspect it, modify it, or handle its response. These functions are interceptors. They are the mechanism for cross-cutting concerns — authentication, logging, error handling, caching, loading indicators, retry logic — that would otherwise be duplicated in every service method. Instead of adding an Authorization header in each get call, one interceptor adds it everywhere. Instead of showing a spinner in every component, one interceptor tracks request counts. Instead of handling errors at each call site, one interceptor centralizes the response to 401 or 500. This chapter covers what interceptors are, how to write them in the functional style that Angular now recommends, how to register them, how they compose in a chain, and the patterns that make them reliable. It builds directly on Angular 35, where HttpClient was introduced, and it treats interceptors as the middleware layer that turns a collection of HTTP calls into a coherent, policy-driven API client.

Key point: An interceptor is a function with the signature (req: HttpRequest<unknown>, next: HttpHandlerFn) => Observable<HttpEvent<unknown>>. It receives the outgoing request, may modify it by cloning, must call next(req) to pass it along the chain, and may transform the response or handle errors. Interceptors are registered with withInterceptors([...]) in provideHttpClient, and they run in the order listed on the way out, and in reverse order on the way back. Functional interceptors are preferred over the older class-based form because they have predictable ordering, work naturally with inject(), and do not require a provider.


Why interceptors exist

An HTTP request is rarely a bare request. In a real application it carries authentication headers, a correlation ID, a content type, a base URL, a retry policy, and often a loading state. On the way back it may carry a refreshed token, a cached response, an error to translate, or a 401 to react to. These concerns are not specific to any single request — they apply to all requests, or to a class of requests.

The naive approach is to handle each concern at each call site. That produces duplication: every service method repeats the header setup, the error handling, the spinner toggle. It produces inconsistency: one method forgets the header, another handles the error differently. And it produces coupling: the concern is entangled with the business logic of the request.

An interceptor solves this by moving the concern out of the call site and into a function that runs for every request. The service method describes what it wants — a URL and a method — and the interceptor handles how the request is made.

Why functional interceptors are the modern form. Angular originally provided interceptors as classes implementing HttpInterceptor and registered with an HTTP_INTERCEPTORS provider. This worked but had two problems: the ordering of interceptors depended on the provider ordering, which was easy to get wrong, and the class form required a provider for each interceptor. The functional form, registered through withInterceptors([...]), makes the order explicit in the array and uses inject() for dependencies, which is more consistent with the rest of modern Angular.

Why the observable chain matters. An interceptor returns an Observable of the response events. This means it can transform the response, retry the request, delay it, cancel it, or replace it with a cached value — all as RxJS operators on the returned stream. The full power of RxJS is available at the interception point, which is why caching and retry interceptors are so natural.

Why interceptors should be small. Each interceptor should do one thing. A single interceptor that handles authentication, logging, caching, and error handling is hard to reason about and hard to order relative to other interceptors. Several small interceptors, each with a clear purpose, compose better and are easier to test.

Why the request is immutable. HttpRequest instances are immutable. An interceptor cannot set a header on the request directly; it must clone the request with the change and pass the clone to next. This is not an inconvenience — it means an interceptor cannot accidentally mutate the request for later interceptors or for the caller. Each interceptor sees the request as it was given to it, and produces a new request for the next one.


Writing a functional interceptor

The basic shape of a functional interceptor is a function that receives the request and a next handler, optionally modifies the request, and returns the result of calling next.

import { HttpInterceptorFn } from '@angular/common/http';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log(`→ ${req.method} ${req.url}`);
  return next(req);
};

This interceptor logs the outgoing request and passes it along unchanged. It is the simplest useful interceptor, and it shows the two required actions: inspect the request, call next with it.

Modifying the request. To change the request, clone it with the desired modification and pass the clone to next.

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token');
  if (!token) {
    return next(req);
  }
  const cloned = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });
  return next(cloned);
};

The clone method produces a new HttpRequest with the change applied. The original request is untouched. The clone is what next receives, and every interceptor downstream sees the modified request.

Why setHeaders is preferred over setting headers directly. The clone method accepts several options: setHeaders replaces or adds individual headers, headers replaces the entire header set, and params replaces the query parameters. setHeaders is the convenient form for adding a header without disturbing the existing ones. The Authorization header is added or overwritten, and the rest of the headers are preserved.

Transforming the response. The return value of next is an Observable of the response. Operators can be applied to it.

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        // handle unauthorized
      }
      return throwError(() => error);
    }),
  );
};

The catchError operator intercepts the error, performs a side effect, and rethrows so the caller still receives the failure. If the interceptor handled the error completely and returned a fallback value, the caller would see success. Both are valid; the choice depends on whether the caller needs to know the request failed.

Why the interceptor must return the observable. The return value of the interceptor is the response the caller will receive. An interceptor that does not return anything breaks the chain — the caller’s subscription never receives a value, and the request may never be sent. The return next(...) is the mechanism that keeps the chain alive.


Registration and ordering

Interceptors are registered when the HTTP client is provided. The withInterceptors feature takes an array of interceptor functions, and the order in the array is the order they run.

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor,
        loggingInterceptor,
        errorInterceptor,
      ]),
    ),
  ],
};

With this registration, a request flows through authInterceptor first, then loggingInterceptor, then errorInterceptor, then to the backend. The response flows back through errorInterceptor, then loggingInterceptor, then authInterceptor, then to the caller.

Why the order matters. Interceptors are middleware, and the order determines which one sees the request first and which one sees the response last. The auth interceptor should run before the logging interceptor if the log should show the final headers, or after if the log should show the request as the caller made it. The error interceptor should be near the end so it catches errors from all the earlier interceptors, or near the beginning so it can transform the request before other interceptors see it. The order is a design decision, and it is explicit in the array.

Why the response order is reversed. The chain is a stack. Each interceptor calls next, which invokes the next interceptor, which calls next, and so on until the backend is reached. The response propagates back up the stack. This is why the last interceptor in the array is the first to see the response.

Why functional interceptors can use inject(). A functional interceptor runs in an injection context when it is invoked, so inject() works inside it. This means the interceptor can inject services — an auth service, a loading service, a notification service — without being a class with a constructor. The pattern is:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.getToken();
  // ...
};

The inject call happens each time the interceptor runs, which is fine because the injection is cheap and the service instance is the same.

Why class-based interceptors still exist. The older form is still supported through withInterceptorsFromDi(), which reads interceptors from the DI container. Existing code that uses classes continues to work. New code should use the functional form. Mixing both is possible but the ordering between the two groups is determined by the registration order of the features, and it is easy to get wrong. For a new codebase, one form or the other is the cleaner choice.


Common interceptor patterns

Several patterns appear in nearly every Angular application. Each is a small function that handles one concern.

Authentication. Add the bearer token to every request that targets the API.

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.token();
  if (!token || !req.url.startsWith('/api')) {
    return next(req);
  }
  return next(req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  }));
};

The interceptor checks that a token exists and that the URL is for the API before adding the header. This avoids sending the token to third-party services, which would be a security leak. The URL check is a simple but important detail.

Error handling. Translate HTTP errors into a consistent form and react to specific status codes.

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const notifications = inject(NotificationService);
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 0) {
        notifications.error('Network error');
      } else if (error.status >= 500) {
        notifications.error('Server error');
      } else if (error.status === 403) {
        notifications.error('Not authorized');
      }
      return throwError(() => error);
    }),
  );
};

The interceptor classifies the error by status and shows a message. It rethrows so the caller can still react — for example, a component might want to clear a form or redirect. The interceptor handles the general case; the caller handles the specific one.

Loading indicator. Track the number of in-flight requests and show a spinner while any are active.

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(LoadingService);
  loading.start();
  return next(req).pipe(
    finalize(() => loading.stop()),
  );
};

The finalize operator runs when the observable completes or errors, which is the right place to decrement the counter. Because the observable completes on both success and failure, the counter stays balanced. The service increments on start and decrements on stop, and the spinner is visible when the counter is above zero.

Retry. Retry failed requests a fixed number of times with a delay.

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') {
    return next(req);
  }
  return next(req).pipe(
    retry({ count: 2, delay: 1000 }),
  );
};

The interceptor only retries GET requests, because retrying a POST can create duplicate resources. The retry operator from RxJS takes a configuration object with the count and delay. This is a case where the interceptor’s policy is meaningful — it applies a rule that would be easy to forget at each call site.

Caching. Return a cached response for GET requests without hitting the network.

const cache = new Map<string, HttpResponse<unknown>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') {
    return next(req);
  }
  const cached = cache.get(req.urlWithParams);
  if (cached) {
    return of(cached.clone());
  }
  return next(req).pipe(
    tap((event) => {
      if (event instanceof HttpResponse) {
        cache.set(req.urlWithParams, event.clone());
      }
    }),
  );
};

The interceptor checks the cache before the request is made, and returns the cached response if present. Otherwise it forwards the request and stores the response on the way back. The clone on the cached response is necessary because HttpResponse instances are immutable and a fresh one is needed for each subscriber.

Base URL. Prepend a base URL to relative paths.

export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.url.startsWith('http')) {
    return next(req);
  }
  return next(req.clone({ url: `${environment.apiUrl}${req.url}` }));
};

The interceptor leaves absolute URLs alone and prefixes relative ones. This lets services use short paths like /users and keeps the base URL configuration in one place.

Why these patterns compose without conflict. Each interceptor handles one concern, and the concerns are largely independent. The auth interceptor adds a header; the loading interceptor toggles a counter; the error interceptor classifies failures. They do not interfere with each other because each one operates on a different part of the request or response. When they do interact — for example, a retry interceptor and a loading interceptor — the interaction is manageable by ordering: the loading interceptor should be outside the retry interceptor so the spinner stays visible across retries.


Testing interceptors

An interceptor is a pure function of its inputs, which makes it straightforward to test. The test provides a request and a next function, and asserts on what the interceptor returns.

import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';

describe('authInterceptor', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([authInterceptor])),
        provideHttpClientTesting(),
      ],
    });
  });

  it('adds the Authorization header', () => {
    const http = TestBed.inject(HttpClient);
    const httpMock = TestBed.inject(HttpTestingController);

    http.get('/api/users').subscribe();

    const req = httpMock.expectOne('/api/users');
    expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
    req.flush([]);
  });
});

The test uses HttpTestingController to intercept the request and assert on it. The interceptor runs as part of the HTTP client, so the test exercises the real chain. This is the recommended way to test interceptors because it tests them in context rather than in isolation.

Why the unit-test approach is also valid. An interceptor can be called directly with a mock request and a mock next, and the result can be inspected. This is faster and more focused. The two approaches answer different questions: the integration test verifies that the interceptor is registered and runs, and the unit test verifies its logic in isolation. Both are useful.

Why testing the ordering is worth doing. When two interceptors interact — an auth interceptor and a logging interceptor — a test that asserts the log contains the auth header verifies the ordering. These tests catch the mistakes that come from registering interceptors in the wrong order, which is otherwise a silent bug.


Complete Example Session

import { ApplicationConfig, inject } from '@angular/core';
import {
  provideHttpClient,
  withInterceptors,
  HttpInterceptorFn,
  HttpErrorResponse,
  HttpResponse,
} from '@angular/common/http';
import { catchError, retry, tap, finalize, throwError, of } from 'rxjs';
import { Injectable, signal } from '@angular/core';

// ============================================
// PART 1: SERVICES THE INTERCEPTORS NEED
// ============================================

@Injectable({ providedIn: 'root' })
export class AuthService {
  private readonly _token = signal<string | null>(null);
  readonly token = this._token.asReadonly();
  setToken(t: string | null): void { this._token.set(t); }
}

@Injectable({ providedIn: 'root' })
export class LoadingService {
  private readonly _count = signal(0);
  readonly active = signal(false);
  start(): void {
    this._count.update((c) => c + 1);
    this.active.set(true);
  }
  stop(): void {
    this._count.update((c) => Math.max(0, c - 1));
    this.active.set(this._count() > 0);
  }
}

@Injectable({ providedIn: 'root' })
export class NotificationService {
  readonly messages = signal<string[]>([]);
  error(message: string): void {
    this.messages.update((m) => [...m, message]);
  }
}

// ============================================
// PART 2: AUTH INTERCEPTOR
// ============================================

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const token = auth.token();
  if (!token || !req.url.startsWith('/api')) {
    return next(req);
  }
  return next(req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  }));
};

// ============================================
// PART 3: LOADING INTERCEPTOR
// ============================================

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
  const loading = inject(LoadingService);
  loading.start();
  return next(req).pipe(
    finalize(() => loading.stop()),
  );
};

// ============================================
// PART 4: ERROR INTERCEPTOR
// ============================================

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const notifications = inject(NotificationService);
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 0) {
        notifications.error('Network error — check your connection');
      } else if (error.status >= 500) {
        notifications.error('Server error — try again later');
      } else if (error.status === 403) {
        notifications.error('Not authorized');
      }
      return throwError(() => error);
    }),
  );
};

// ============================================
// PART 5: RETRY INTERCEPTOR
// ============================================

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') {
    return next(req);
  }
  return next(req).pipe(
    retry({ count: 2, delay: 1000 }),
  );
};

// ============================================
// PART 6: CACHE INTERCEPTOR
// ============================================

const httpCache = new Map<string, HttpResponse<unknown>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') {
    return next(req);
  }
  const cached = httpCache.get(req.urlWithParams);
  if (cached) {
    return of(cached.clone());
  }
  return next(req).pipe(
    tap((event) => {
      if (event instanceof HttpResponse) {
        httpCache.set(req.urlWithParams, event.clone());
      }
    }),
  );
};

// ============================================
// PART 7: REGISTRATION
// ============================================

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor,       // runs first on the way out
        loadingInterceptor,
        retryInterceptor,
        cacheInterceptor,
        errorInterceptor,      // runs first on the way back
      ]),
    ),
  ],
};

// ============================================
// PART 8: THE ORDER EXPLAINED
// ============================================

// Request path:
//   caller → auth → loading → retry → cache → error → backend
//
// Response path:
//   backend → error → cache → retry → loading → auth → caller
//
// Auth runs first so the token is on the request before anything else.
// Loading wraps everything so the spinner is visible across retries.
// Retry is inside loading so the spinner does not flicker between attempts.
// Cache is inside retry so a cache hit does not trigger a retry.
// Error is last so it sees errors from every earlier interceptor.

// ============================================
// PART 9: USAGE FROM A SERVICE
// ============================================

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);

  getUsers() {
    return this.http.get<User[]>('/api/users');
    // Auth header added by authInterceptor
    // Loading tracked by loadingInterceptor
    // Retried by retryInterceptor
    // Cached by cacheInterceptor
    // Errors handled by errorInterceptor
  }
}

// ============================================
// PART 10: TESTING
// ============================================

describe('authInterceptor', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(withInterceptors([authInterceptor])),
        provideHttpClientTesting(),
      ],
    });
    TestBed.inject(AuthService).setToken('test-token');
  });

  it('adds the Authorization header to API requests', () => {
    const http = TestBed.inject(HttpClient);
    const httpMock = TestBed.inject(HttpTestingController);

    http.get('/api/users').subscribe();

    const req = httpMock.expectOne('/api/users');
    expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
    req.flush([]);
  });

  it('does not add the header to external requests', () => {
    const http = TestBed.inject(HttpClient);
    const httpMock = TestBed.inject(HttpTestingController);

    http.get('https://external.example.com/data').subscribe();

    const req = httpMock.expectOne('https://external.example.com/data');
    expect(req.request.headers.has('Authorization')).toBe(false);
    req.flush({});
  });
});

The example shows the services the interceptors depend on, the five interceptors, their registration in a specific order, the reasoning for the order, a service that uses them, and tests that verify the behavior.


Quick Reference

Interceptor Signature

PartType
RequestHttpRequest<unknown>
NextHttpHandlerFn
ReturnObservable<HttpEvent<unknown>>

Registration

FeaturePurpose
withInterceptors([...])Functional interceptors
withInterceptorsFromDi()Class-based interceptors
OrderArray order on request, reverse on response

Request Modification

MethodEffect
req.clone({ setHeaders })Add/overwrite headers
req.clone({ headers })Replace all headers
req.clone({ params })Replace query params
req.clone({ url })Change URL
req.clone({ body })Change body
req.clone({ method })Change method

Common Operators

OperatorPurpose
catchErrorHandle errors
tapSide effects
finalizeCleanup on complete/error
retryRetry failed requests
delayDelay the request
ofReturn a cached value

Common Interceptors

InterceptorPurpose
AuthAdd Authorization header
LoadingTrack in-flight requests
ErrorClassify and notify
RetryRetry idempotent requests
CacheReturn cached responses
Base URLPrefix relative paths
LoggingLog requests and responses
Correlation IDAdd a request ID header

Ordering Rules of Thumb

InterceptorPositionReason
AuthEarlyToken on request first
LoadingEarlyWraps everything
RetryMiddleInside loading
CacheMiddleInside retry
ErrorLateSees all errors
LoggingAnyDepends on what to log

Best Practices

Do This:

// One concern per interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => { ... }; // ✅

// Clone before modifying
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); // ✅

// Always return next(...)
return next(req);                                              // ✅

// Use inject() for dependencies
const auth = inject(AuthService);                              // ✅

// Check the URL before adding sensitive headers
if (!req.url.startsWith('/api')) return next(req);             // ✅

// Use finalize for cleanup
return next(req).pipe(finalize(() => loading.stop()));         // ✅

// Only retry idempotent requests
if (req.method !== 'GET') return next(req);                    // ✅

// Test interceptors with HttpTestingController
const req = httpMock.expectOne('/api/users');                  // ✅

Don’t Do This:

// Don't mutate the request
req.headers.set('Authorization', token); // immutable           // ⚠️

// Don't forget to return next
next(req);  // return value lost                                // ⚠️

// Don't swallow errors silently
catchError(() => of(null));  // caller sees success             // ⚠️

// Don't add the token to every URL
// External services should not receive it                      // ⚠️

// Don't retry non-idempotent requests
retry(3);  // on a POST may create duplicates                   // ⚠️

// Don't do everything in one interceptor
// Split into focused interceptors                              // ⚠️

Common Pitfalls

PitfallProblemSolution
Mutating the requestImmutable, no effectUse req.clone()
Not returning nextChain brokenreturn next(req)
Swallowing errorsCaller sees successRethrow or return a result type
Wrong orderAuth header missing in logsReorder the array
Token sent externallySecurity leakCheck the URL
Retry on POSTDuplicate resourcesOnly retry GET
Loading counter unbalancedSpinner stuckUse finalize
Cache never invalidatedStale dataAdd a TTL or clear mechanism
Interceptor not registeredRuns for nothingAdd to withInterceptors
Class + functional mixedUnpredictable orderPick one form

Real-World Examples

1. Bearer token

return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));

2. API key header

return next(req.clone({ setHeaders: { 'X-Api-Key': apiKey } }));

3. Correlation ID

const id = crypto.randomUUID();
return next(req.clone({ setHeaders: { 'X-Correlation-Id': id } }));

4. Loading spinner

loading.start();
return next(req).pipe(finalize(() => loading.stop()));

5. Global error notification

catchError((e: HttpErrorResponse) => {
  notifications.error(messageFor(e.status));
  return throwError(() => e);
});

6. Retry with backoff

return next(req).pipe(retry({ count: 3, delay: (i) => i * 1000 }));

7. Cache GET responses

const cached = cache.get(req.urlWithParams);
if (cached) return of(cached.clone());

8. Base URL prefix

return next(req.clone({ url: `${environment.apiUrl}${req.url}` }));

9. Request timing

const start = Date.now();
return next(req).pipe(tap(() => console.log(`${req.url}: ${Date.now() - start}ms`)));

10. Token refresh on 401

catchError((e: HttpErrorResponse) => {
  if (e.status === 401) {
    return auth.refresh().pipe(switchMap(() => next(req.clone({ setHeaders: { Authorization: `Bearer ${auth.token()}` } }))));
  }
  return throwError(() => e);
});

Visual: The Interceptor Chain

┌──────────────────────────────────────────────────────────┐
│  CALLER                                                  │
│    │                                                     │
│    ▼                                                     │
│  authInterceptor                                         │
│    │  clone + Authorization header                       │
│    ▼                                                     │
│  loadingInterceptor                                      │
│    │  loading.start()                                    │
│    ▼                                                     │
│  retryInterceptor                                        │
│    │  retry({ count: 2 })                                │
│    ▼                                                     │
│  errorInterceptor                                        │
│    │  catchError                                         │
│    ▼                                                     │
│  BACKEND                                                 │
│    │                                                     │
│    ▼                                                     │
│  errorInterceptor  (response)                            │
│    │                                                     │
│    ▼                                                     │
│  retryInterceptor  (retry on failure)                    │
│    │                                                     │
│    ▼                                                     │
│  loadingInterceptor                                      │
│    │  finalize → loading.stop()                          │
│    ▼                                                     │
│  authInterceptor  (response)                             │
│    │                                                     │
│    ▼                                                     │
│  CALLER                                                  │
│                                                          │
│  Request: top → bottom                                   │
│  Response: bottom → top                                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Request Immutability

┌──────────────────────────────────────────────────────────┐
│  Caller creates:                                         │
│    HttpRequest { url: '/api/users', headers: {} }        │
│                                                          │
│  authInterceptor:                                        │
│    cloned = req.clone({                                  │
│      setHeaders: { Authorization: 'Bearer x' }           │
│    })                                                    │
│    next(cloned)                                          │
│                                                          │
│  Original req is unchanged.                              │
│  The clone flows forward.                                │
│                                                          │
│  loggingInterceptor sees the clone with the header.      │
│  If it clones again, a third request is created.         │
│                                                          │
│  Each interceptor sees the request as given to it,       │
│  and produces a new one for the next.                    │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Error Handling Flow

┌──────────────────────────────────────────────────────────┐
│  Backend returns 500                                     │
│       │                                                  │
│       ▼                                                  │
│  errorInterceptor.catchError                             │
│       │                                                  │
│       ├── Classify by status                             │
│       │                                                  │
│       ├── Show notification                              │
│       │                                                  │
│       └── throwError(() => error)                        │
│              │                                           │
│              ▼                                           │
│       Caller's error callback receives the error         │
│                                                          │
│  If the interceptor returned of(null):                   │
│       │                                                  │
│       ▼                                                  │
│  Caller's success callback receives null                 │
│  The failure is hidden.                                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Loading Counter

┌──────────────────────────────────────────────────────────┐
│  Request 1 starts:                                       │
│    count = 1  → spinner ON                               │
│                                                          │
│  Request 2 starts:                                       │
│    count = 2  → spinner ON                               │
│                                                          │
│  Request 1 completes:                                    │
│    count = 1  → spinner ON (still one in flight)         │
│                                                          │
│  Request 2 completes:                                    │
│    count = 0  → spinner OFF                              │
│                                                          │
│  finalize runs on both success and error,                │
│  so the counter stays balanced.                          │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: When to Use an Interceptor

┌──────────────────────────────────────────────────────────┐
│  Does the concern apply to many requests?                │
│       │                                                  │
│       ├── No  ──► Handle at the call site                │
│       │                                                  │
│       └── Yes                                            │
│            │                                             │
│            ├── Is it about the request?                  │
│            │      └── Interceptor (on the way out)       │
│            │                                             │
│            ├── Is it about the response?                 │
│            │      └── Interceptor (on the way back)      │
│            │                                             │
│            ├── Is it about errors?                       │
│            │      └── Interceptor with catchError        │
│            │                                             │
│            └── Is it about caching or retrying?          │
│                   └── Interceptor with RxJS operators    │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

ItemValue
Signature(req, next) => Observable<HttpEvent>
RegistrationwithInterceptors([...])
OrderArray order out, reverse back
RequestImmutable, clone to modify
Dependenciesinject() inside the function
ErrorscatchError, rethrow or handle
Cleanupfinalize
CommonAuth, loading, error, retry, cache
TestingHttpTestingController
Class formwithInterceptorsFromDi()

Key takeaways:

  • Interceptors are middleware for HTTP — they run for every request through HttpClient and handle cross-cutting concerns in one place
  • The functional form is preferred(req, next) => Observable with inject() for dependencies and explicit ordering through withInterceptors
  • The request is immutable — modification requires req.clone(), which produces a new request for the next interceptor and leaves the original untouched
  • next must always be called — the return value is the response the caller receives, and omitting it breaks the chain
  • Ordering is a design decision — auth early, error late, loading wrapping everything, retry inside loading, cache inside retry
  • Each interceptor should do one thing — small, focused interceptors compose better than one that handles everything
  • Errors should be classified and rethrown — handle the general case in the interceptor and let the caller handle the specific case
  • finalize is the right operator for cleanup — it runs on both success and error, which keeps counters balanced
  • Only retry idempotent requests — retrying a POST can create duplicate resources
  • Testing with HttpTestingController exercises the real chain and catches ordering mistakes that unit tests miss

Remember: HTTP interceptors turn a collection of independent requests into a coherent API client with shared policy. Authentication, loading, error handling, retry, and caching are concerns that belong in the pipeline, not in each service method. The pattern is small — a function with a request and a next — but the discipline of keeping each interceptor focused and ordering them deliberately is what makes the pipeline reliable.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!