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.
HttpRequestinstances are immutable. An interceptor cannot set a header on the request directly; it must clone the request with the change and pass the clone tonext. 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
| Part | Type |
|---|---|
| Request | HttpRequest<unknown> |
| Next | HttpHandlerFn |
| Return | Observable<HttpEvent<unknown>> |
Registration
| Feature | Purpose |
|---|---|
withInterceptors([...]) | Functional interceptors |
withInterceptorsFromDi() | Class-based interceptors |
| Order | Array order on request, reverse on response |
Request Modification
| Method | Effect |
|---|---|
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
| Operator | Purpose |
|---|---|
catchError | Handle errors |
tap | Side effects |
finalize | Cleanup on complete/error |
retry | Retry failed requests |
delay | Delay the request |
of | Return a cached value |
Common Interceptors
| Interceptor | Purpose |
|---|---|
| Auth | Add Authorization header |
| Loading | Track in-flight requests |
| Error | Classify and notify |
| Retry | Retry idempotent requests |
| Cache | Return cached responses |
| Base URL | Prefix relative paths |
| Logging | Log requests and responses |
| Correlation ID | Add a request ID header |
Ordering Rules of Thumb
| Interceptor | Position | Reason |
|---|---|---|
| Auth | Early | Token on request first |
| Loading | Early | Wraps everything |
| Retry | Middle | Inside loading |
| Cache | Middle | Inside retry |
| Error | Late | Sees all errors |
| Logging | Any | Depends 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
| Pitfall | Problem | Solution |
|---|---|---|
| Mutating the request | Immutable, no effect | Use req.clone() |
Not returning next | Chain broken | return next(req) |
| Swallowing errors | Caller sees success | Rethrow or return a result type |
| Wrong order | Auth header missing in logs | Reorder the array |
| Token sent externally | Security leak | Check the URL |
Retry on POST | Duplicate resources | Only retry GET |
| Loading counter unbalanced | Spinner stuck | Use finalize |
| Cache never invalidated | Stale data | Add a TTL or clear mechanism |
| Interceptor not registered | Runs for nothing | Add to withInterceptors |
| Class + functional mixed | Unpredictable order | Pick 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
| Item | Value |
|---|---|
| Signature | (req, next) => Observable<HttpEvent> |
| Registration | withInterceptors([...]) |
| Order | Array order out, reverse back |
| Request | Immutable, clone to modify |
| Dependencies | inject() inside the function |
| Errors | catchError, rethrow or handle |
| Cleanup | finalize |
| Common | Auth, loading, error, retry, cache |
| Testing | HttpTestingController |
| Class form | withInterceptorsFromDi() |
Key takeaways:
- Interceptors are middleware for HTTP — they run for every request through
HttpClientand handle cross-cutting concerns in one place - The functional form is preferred —
(req, next) => Observablewithinject()for dependencies and explicit ordering throughwithInterceptors - The request is immutable — modification requires
req.clone(), which produces a new request for the next interceptor and leaves the original untouched nextmust 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
finalizeis the right operator for cleanup — it runs on both success and error, which keeps counters balanced- Only retry idempotent requests — retrying a
POSTcan create duplicate resources - Testing with
HttpTestingControllerexercises 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!