Angular's HttpClient is powerful, but when you're building a real-world application, you quickly find yourself repeating tasks for every API call: attaching JWT tokens, handling server errors, implementing global loading indicators, or logging performance.
This is where HTTP Interceptors shine. They allow you to define middleware logic that intercepts and modifies outgoing requests and incoming responses.
In recent versions of Angular, the landscape changed dramatically. Class-based interceptors, which rely on dependency injection and boilerplate interfaces, were superseded by Functional Interceptors.
This article is a deep dive into functional interceptors, focused on modern best practices, packed with code snippets and real-world patterns.
1. The core concept: The "toll booth" analogy
Think of an HTTP Interceptor as a toll booth on a highway.
When your application components make a request (HttpClient.get()), the "car" (the request) must pass through the booth. The attendant can:
- inspect the car,
- modify it (add a "paid" sticker/header),
- or turn it back (if unauthorized).
When the server sends a response back, the car returns through the same toll booth, allowing the attendant to handle errors or log travel time before the car reaches the user.
2. Defining a Functional Interceptor: HttpInterceptorFn
In modern Angular, an interceptor is a pure function that adheres to the HttpInterceptorFn type signature.
Let’s define a minimal, boilerplate example:
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
export const minimalInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
// 1. You receive the request 'req'
console.log('Request outgoing:', req.url);
// 2. You pass it to the 'next' handler
return next(req);
};
Key players:
-
HttpRequest: The incoming request object. Note that requests are immutable. -
HttpHandlerFn: A function that represents the next interceptor in the chain, or the final backend handler if there are no more interceptors. Callingnext(req)passes the request forward. - The Return Value: It must return an
Observable<HttpEvent<any>>.
3. Immutability & Cloning: The modifying rule
Because HttpRequest objects are immutable, you cannot do this:
// ❌ WRONG: Requests are immutable
req.url = 'https://new-api.com' + req.url;
Instead, you must clone the request and apply changes during the cloning process. Angular optimizes this process.
// ✅ CORRECT: Clone and Modify
const modifiedReq = req.clone({
url: `https://api.my-app.com${req.url}`,
setHeaders: {
'Content-Type': 'application/json'
}
});
return next(modifiedReq);
4. Registering your Interceptors (Dependency Injection)
Interceptors are not registered via @Injectable(). They are provided where you configure your HttpClient. In modern applications, this is typically in app.config.ts.
You must use the provideHttpClient() function and its optional withInterceptors([]) helper.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { minimalInterceptor } from './interceptors/minimal.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
// Provide HttpClient with a list of active functional interceptors
provideHttpClient(
withInterceptors([
minimalInterceptor,
// nextInterceptor,
// errorInterceptor
])
)
]
};
Crucial note on order: The order matters. Interceptors run sequentially in the order they are listed in the array: Minimal -> Next -> Error. For responses, the order is reversed.
5. Real-world power moves: 4 advanced patterns
Let’s look at practical implementations you’ll likely need in production.
Pattern A: The Authentication Header (JWT) Interceptor
The most common use case. Attach a bearer token if the user is authenticated.
Because functional interceptors run inside the injection context, you can inject services directly.
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// 1. Inject the AuthService to get the token
const authService = inject(AuthService);
const authToken = authService.getToken();
// 2. If no token, just forward the original request
if (!authToken) {
return next(req);
}
// 3. Clone and add the Authorization header
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${authToken}`
}
});
return next(authReq);
};
Pattern B: Global Error Handling with RxJS
Don't use try/catch blocks in every component. Use the interceptor to catch HTTP errors globally, handles 401s, and shows friendly messages to the user.
We tap into the response stream using pipe().
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { tap, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { inject } from '@angular/core';
import { NotificationService } from './services/notification.service';
import { Router } from '@angular/router';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notifier = inject(NotificationService);
const router = inject(Router);
return next(req).pipe(
// tap() allows us to inspect SUCCESSFUL responses/events
tap({
next: (event) => {
// You could check status codes here if needed (e.g., 201 Created)
}
}),
// catchError() handles FAILED responses
catchError((error: HttpErrorResponse) => {
let errorMessage = 'An unknown error occurred.';
if (error.error instanceof ErrorEvent) {
// Client-side error (e.g., network issue)
errorMessage = `Client Error: ${error.error.message}`;
} else {
// Server-side error
switch (error.status) {
case 401: // Unauthorized
errorMessage = 'Your session has expired. Please log in again.';
// Handle auto-logout or token refresh logic here
router.navigate(['/login']);
break;
case 403: // Forbidden
errorMessage = 'You do not have permission to access this resource.';
break;
case 404: // Not Found
errorMessage = 'The requested resource was not found.';
break;
case 500: // Internal Server Error
errorMessage = 'The server encountered an error. Please try again later.';
break;
}
}
// Show the notification to the user
notifier.showError(errorMessage);
// We MUST re-throw the error so the calling component knows it failed
return throwError(() => error);
})
);
};
Pattern C: Performance logging
Let’s measure how long our API requests take. We record the start time when the request is sent and the end time when the final response event is received.
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { tap, finalize } from 'rxjs/operators';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const startTime = Date.now();
let status: string;
return next(req).pipe(
// Monitor events
tap({
next: (event) => {
if (event instanceof HttpResponse) {
status = 'succeeded';
}
},
error: (error) => {
status = 'failed';
}
}),
// finalize runs when the observable completes or errors
finalize(() => {
const elapsedTime = Date.now() - startTime;
const message = `${req.method} "${req.urlWithParams}" ${status} in ${elapsedTime}ms.`;
// We use console.info here to separate it from app logs
console.info(message);
})
);
};
Pattern D: A modern loading spinner
A global spinner that shows whenever any API call is pending. We use an interceptor to increment a counter in a service, and finalize to decrement it, even if the request fails.
// 1. The Loading Service
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class LoadingService {
// Using Angular Signals for efficient reactivity
private activeRequests = signal<number>(0);
readonly isLoading = signal<boolean>(false);
show() {
this.activeRequests.update(count => count + 1);
this.isLoading.set(true);
}
hide() {
this.activeRequests.update(count => Math.max(0, count - 1));
if (this.activeRequests() === 0) {
this.isLoading.set(false);
}
}
}
// 2. The Interceptor
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { LoadingService } from './loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
// Start the spinner
loadingService.show();
return next(req).pipe(
// Always hide the spinner when the request stream completes (success or error)
finalize(() => {
loadingService.hide();
})
);
};
Summary of Best Practices
-
Prefer Functional Interceptors: Do not write class-based interceptors (
implements HttpInterceptor) for new code. They are verbose and less composable. -
Order matters: Remember that your registered array (
[auth, error, logging]) dictates the execution order. -
Clone, don't modify: Always use
req.clone()when changing a request. -
Handle errors in the chain: Use RxJS operators like
catchErrorandtapwithin the interceptor to handle global failure logic. Do not handle error UI inside your components unless it is highly specific. -
Always rethrow errors: If you catch an error in an interceptor, you must use
throwErrorat the end to pass the error down the chain. Otherwise, your component’s.subscribe(..., error => {})block will never fire. -
Avoid bloat: Interceptors should be lean. If your logging logic gets complicated, move it into a dedicated
LoggingServiceand inject that service into the interceptor.
Functional Interceptors are a perfect example of how Angular is modernizing: removing boilerplate, embracing functional programming patterns, and optimizing performance. When used correctly, they are the key to a clean, scalable, and resilient application architecture.
Conclusion
Angular’s shift toward Functional Interceptors is more than just a syntax change; it’s a move toward more composable, readable, and tree-shakable code. By moving cross-cutting concerns like authentication, error handling, and loading states into interceptors, you keep your components focused on what they do best: managing the UI.
As you implement these in your own projects, remember:
- Keep them single-purpose: It’s better to have four small interceptors than one giant "God-interceptor."
-
Mind the order: Your
provideHttpClientarray is your execution pipeline. - Embrace Signals: Combine interceptors with Angular Signals (like in our Loading Spinner example) for a truly modern, reactive experience.
What are you using interceptors for in your current project?
Top comments (0)