HTTP Interceptors
Interceptors are middleware for HttpClient, one ordered place to attach auth headers, retry requests, or normalize errors app-wide
Modern Angular only No equivalent exists in the original 100 Days series. Written fresh for Angular v22.
Lead with this: Interceptors are middleware for HttpClient — every
request and response passes through them in order, giving you one place to
add auth headers, retry failed requests, show a loading spinner, or normalize
errors across your entire app.
What it is
Every call to HttpClient travels through a pipeline of interceptors before
reaching the backend, and every response travels back through them in reverse.
Each interceptor is a function that can:
- Inspect or modify the outgoing request (add headers, change the URL, attach context)
- Inspect or modify the incoming response (log timing, transform data, throw on errors)
- Short-circuit the pipeline entirely (return a cached response without hitting the network)
- Coordinate UI (increment a loading counter, decrement it when done)
You register interceptors once in provideHttpClient(withInterceptors([...])).
They run for every request the whole app makes — no per-service wiring needed.
Angular v22 uses functional interceptors as the recommended form — plain
functions typed as HttpInterceptorFn. The older class-based form still works
via withInterceptorsFromDi() but is considered legacy.
How it works under the hood
Old mechanism — class-based interceptors and HTTP_INTERCEPTORS
The original interceptor system (Angular 4+) used the HttpInterceptor interface:
// Old class-based interceptor (Angular 4–14)@Injectable()export class AuthInterceptor implements HttpInterceptor { constructor(private authService: AuthService) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> { const token = this.authService.getToken(); const cloned = req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) }); return next.handle(cloned); // next.handle() — method call on an object }}Registered via the HTTP_INTERCEPTORS multi-token:
providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },]Two problems with this approach:
Ordering was fragile. Interceptors registered across different NgModules or providers could run in a non-obvious order depending on module import order. In complex apps with lazy-loaded modules providing their own interceptors, the actual execution order was hard to predict.
Boilerplate. Every interceptor required a class, @Injectable(), a
constructor for DI, and a next.handle(req) call (method on an object,
not a direct function call).
New mechanism — functional interceptors and withInterceptors
Functional interceptors are plain TypeScript functions typed as HttpInterceptorFn:
type HttpInterceptorFn = ( req: HttpRequest<unknown>, next: HttpHandlerFn) => Observable<HttpEvent<unknown>>req— the outgoing request (immutable — must usereq.clone()to modify)next— a function (not an object) representing the next interceptor in the chain, or the backend if no more interceptors remain- Return — an
Observable<HttpEvent<unknown>>that callers subscribe to
Because HttpInterceptorFns run inside Angular's injection context, inject()
works directly inside them — no constructor, no @Injectable(), no class needed.
Ordering is deterministic. The interceptors run in the exact array order you
specify in withInterceptors([...]). For requests: left-to-right. For responses
(via the Observable pipe): right-to-left.
Request flow (outbound): withInterceptors([A, B, C]) A → B → C → Backend
Response flow (inbound, via Observable chain): Backend → C → B → A → callerThis mirrors how Express middleware or Koa's compose pattern works — each interceptor wraps the next one as a nested Observable.
Why requests are immutable
HttpRequest objects are frozen — you cannot mutate req.url, req.headers,
or req.body directly. Every modification produces a new request via req.clone().
This immutability means interceptors can't accidentally corrupt the request seen
by subsequent interceptors: each interceptor only sees what was passed to it.
Basic usage
Writing a functional interceptor
// auth.interceptor.tsimport { HttpInterceptorFn } from '@angular/common/http';import { inject } from '@angular/core';import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); // inject() works here const token = authService.token(); // read the signal
if (!token) { return next(req); // no token — pass through unchanged }
// req is immutable — clone and add the header const authedReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
return next(authedReq);};Registering interceptors
// app.config.tsimport { provideHttpClient, withInterceptors } from '@angular/common/http';import { authInterceptor } from './auth.interceptor';import { loggingInterceptor } from './logging.interceptor';import { errorInterceptor } from './error.interceptor';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient( withInterceptors([ loggingInterceptor, // runs first on requests, last on responses authInterceptor, // runs second on requests errorInterceptor, // runs last on requests, first on responses ]) ), ],};req.clone() patterns
req.clone() accepts a partial override of any request property:
// Add a header (without removing existing headers)const newReq = req.clone({ headers: req.headers.set('X-Request-ID', crypto.randomUUID()),});
// Shorthand for setting multiple headers at onceconst newReq = req.clone({ setHeaders: { Authorization: `Bearer ${token}`, 'X-Request-ID': crypto.randomUUID(), },});
// Change the URL (e.g. prefix all requests with the API base URL)const newReq = req.clone({ url: `https://api.example.com${req.url}`,});
// Replace the bodyconst newReq = req.clone({ body: { ...req.body, timestamp: Date.now() },});Class-based interceptors (legacy — still works)
// Old class-based approach — still supported via withInterceptorsFromDi()import { Injectable } from '@angular/core';import { HttpInterceptor, HttpRequest, HttpHandler } from '@angular/common/http';
@Injectable()export class LegacyAuthInterceptor implements HttpInterceptor { constructor(private authService: AuthService) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler) { const token = this.authService.getToken(); return next.handle(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); }}// RegistrationprovideHttpClient( withInterceptorsFromDi(), // enables DI-based (class) interceptors withInterceptors([modernFunctionalInterceptor]), // can mix both),providers: [ { provide: HTTP_INTERCEPTORS, useClass: LegacyAuthInterceptor, multi: true }]Use withInterceptorsFromDi() only for gradual migration — prefer functional
interceptors for all new code. The Angular team has signaled that class-based
interceptors may be deprecated in a future release.
HttpContextToken — per-request metadata for interceptors
Sometimes an interceptor needs per-request configuration — "skip auth for this
request," "cache this one," "use a 5-second timeout." HttpContextToken is a
typed key that can carry any metadata in the request's .context map:
// tokens.ts — define tokens with a default value factoryimport { HttpContextToken } from '@angular/common/http';
export const SKIP_AUTH = new HttpContextToken<boolean>(() => false);export const CACHE_TTL_MS = new HttpContextToken<number>(() => 0);// caching.interceptor.ts — reads the tokenexport const cachingInterceptor: HttpInterceptorFn = (req, next) => { const ttl = req.context.get(CACHE_TTL_MS);
if (ttl === 0) { return next(req); // no caching requested for this request }
const cache = inject(HttpCacheService); const hit = cache.get(req.urlWithParams);
if (hit) { // Short-circuit — return cached response without hitting the network return of(new HttpResponse({ status: 200, body: hit })); }
return next(req).pipe( tap(event => { if (event instanceof HttpResponse && event.ok) { cache.set(req.urlWithParams, event.body, ttl); } }) );};// Call site — attach context to the specific requestthis.http.get<Product[]>('/api/products', { context: new HttpContext() .set(CACHE_TTL_MS, 60_000) // cache for 60 seconds .set(SKIP_AUTH, true), // don't add auth header to this request});Context is mutable (unlike the request itself). If a retried request re-enters the interceptor chain, the same context object is passed — useful for tracking retry count without cloning context.
Real-world patterns
Pattern 1 — Auth interceptor with token refresh
The most common interceptor: add the token to every request, catch 401 responses, refresh the token, and replay the original request:
// auth.interceptor.tsimport { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';import { inject } from '@angular/core';import { catchError, switchMap, throwError } from 'rxjs';import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService);
const addToken = (request: typeof req) => request.clone({ setHeaders: { Authorization: `Bearer ${auth.accessToken()}` } });
return next(addToken(req)).pipe( catchError((error: HttpErrorResponse) => { if (error.status !== 401) { return throwError(() => error); }
// Token expired — refresh and replay return auth.refreshToken().pipe( switchMap(() => next(addToken(req))), // replay with new token catchError(refreshError => { auth.logout(); // refresh also failed — force logout return throwError(() => refreshError); }) ); }) );};Pattern 2 — Loading spinner with signals
A global loading indicator driven by an in-flight request counter:
// http-ui.service.ts@Injectable({ providedIn: 'root' })export class HttpUiService { private _count = signal(0); readonly isLoading = computed(() => this._count() > 0);
increment(): void { this._count.update(n => n + 1); } decrement(): void { this._count.update(n => Math.max(0, n - 1)); }}
// spinner.interceptor.tsimport { finalize } from 'rxjs';
export const spinnerInterceptor: HttpInterceptorFn = (req, next) => { const ui = inject(HttpUiService); ui.increment(); return next(req).pipe(finalize(() => ui.decrement())); // finalize fires on: complete, error, AND unsubscribe (cancel)};<!-- app.component.html -->@if (httpUi.isLoading()) { <app-global-spinner />}finalize is the right operator here — it fires on complete, error, AND when the
subscription is unsubscribed (request cancelled). tap would miss the cancel case.
Pattern 3 — Retry with exponential backoff
// retry.interceptor.tsimport { HttpContextToken, HttpInterceptorFn } from '@angular/common/http';import { retry, timer } from 'rxjs';
export const RETRY_COUNT = new HttpContextToken<number>(() => 3);
export const retryInterceptor: HttpInterceptorFn = (req, next) => { const maxRetries = req.context.get(RETRY_COUNT);
return next(req).pipe( retry({ count: maxRetries, delay: (error, attempt) => { // Only retry on server errors, not client errors if (error.status >= 400 && error.status < 500) { throw error; // don't retry 4xx } // Exponential backoff: 1s, 2s, 4s, 8s... return timer(Math.pow(2, attempt - 1) * 1000); }, }) );};// Call site — override default retry count for critical requeststhis.http.post('/api/critical-action', payload, { context: new HttpContext().set(RETRY_COUNT, 5),});
// Skip retries for this requestthis.http.get('/api/real-time', { context: new HttpContext().set(RETRY_COUNT, 0),});Pattern 4 — API URL prefix interceptor
One interceptor replaces manual base-URL concatenation in every service:
// api-prefix.interceptor.tsexport const apiPrefixInterceptor: HttpInterceptorFn = (req, next) => { // Only prefix relative URLs — don't prefix external calls (CDN, third-party) if (req.url.startsWith('http')) { return next(req); }
return next(req.clone({ url: `https://api.example.com/v2${req.url}`, }));};// Services become much cleaner — no base URL management@Injectable({ providedIn: 'root' })export class UserService { private http = inject(HttpClient);
getUser(id: string) { return this.http.get<User>(`/users/${id}`); // Interceptor rewrites to: https://api.example.com/v2/users/123 }}Testing interceptors
Use provideHttpClientTesting() and HttpTestingController to test interceptors
in isolation:
// auth.interceptor.spec.tsimport { TestBed } from '@angular/core/testing';import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';import { signal } from '@angular/core';import { authInterceptor } from './auth.interceptor';
describe('authInterceptor', () => { let http: HttpClient; let controller: HttpTestingController; const mockToken = signal<string | null>(null);
beforeEach(() => { TestBed.configureTestingModule({ providers: [ provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting(), { provide: AuthService, useValue: { token: mockToken } }, ], }); http = TestBed.inject(HttpClient); controller = TestBed.inject(HttpTestingController); });
afterEach(() => controller.verify()); // no unexpected requests
it('adds Authorization header when token is present', () => { mockToken.set('my-test-token');
http.get('/api/users').subscribe();
const req = controller.expectOne('/api/users'); expect(req.request.headers.get('Authorization')).toBe('Bearer my-test-token'); req.flush([]); });
it('passes through unchanged when no token', () => { mockToken.set(null);
http.get('/api/public').subscribe();
const req = controller.expectOne('/api/public'); expect(req.request.headers.has('Authorization')).toBeFalse(); req.flush({}); });});Common mistakes
Mistake 1 — Mutating the request directly
HttpRequest is immutable. Modifying properties directly throws at runtime:
// ❌ Runtime error — HttpRequest is frozenexport const badInterceptor: HttpInterceptorFn = (req, next) => { req.headers.set('Authorization', token); // no-op in some cases, throws in others return next(req);};
// ✅ Always cloneexport const goodInterceptor: HttpInterceptorFn = (req, next) => { return next(req.clone({ setHeaders: { Authorization: token } }));};Mistake 2 — Forgetting to return next(req) — silent hang
Every interceptor must either call next(req) or return a synthetic Observable.
Forgetting to return next(req) means the request chain stops — the HTTP call
never fires, and the caller's Observable never emits or errors:
// ❌ Missing return — request silently hangs foreverexport const brokenInterceptor: HttpInterceptorFn = (req, next) => { console.log('Request:', req.url); next(req); // called but not returned — Observable is thrown away};
// ✅ Always return next(req) or the pipe chainexport const fixedInterceptor: HttpInterceptorFn = (req, next) => { console.log('Request:', req.url); return next(req);};Mistake 3 — Using tap instead of finalize for cleanup
tap fires on emissions, not on completion or cancellation. Use finalize
for cleanup that must run no matter how the Observable terminates:
// ❌ tap misses cancellation (unsubscribe before response) and error pathsexport const badSpinnerInterceptor: HttpInterceptorFn = (req, next) => { ui.increment(); return next(req).pipe(tap(() => ui.decrement()));};
// ✅ finalize fires on complete, error, AND unsubscribeexport const goodSpinnerInterceptor: HttpInterceptorFn = (req, next) => { ui.increment(); return next(req).pipe(finalize(() => ui.decrement()));};Mistake 4 — Ordering interceptors incorrectly
Request processing is left-to-right; response processing is right-to-left. Putting the error handler before the auth interceptor means the error handler sees requests that haven't been authenticated yet:
// ❌ errorInterceptor runs before authInterceptor on requests// This means it can't retry with a refreshed token from authInterceptorwithInterceptors([errorInterceptor, authInterceptor])
// ✅ Auth adds the token first, then error handler catches 401 and can retrywithInterceptors([authInterceptor, errorInterceptor])Think through both directions when ordering: what order makes sense for outgoing requests, AND what order makes sense for incoming responses?
How this evolved
-
Angular 4 (2017): Class-based interceptors introduced —
HttpInterceptorinterface,HTTP_INTERCEPTORSmulti-token,next.handle(req). Ordering determined by provider declaration order, fragile across NgModules. -
Angular 15 (2022): Functional interceptors introduced via
withInterceptors([...]). Deterministic ordering.inject()available inside interceptor functions. TheHttpContextTokenAPI also stable here (introduced alongside interceptors in Angular 12 but gained prominence with functional interceptors). -
Angular 16 (2023):
withRequestsMadeViaParent()feature — lets a child injector'sHttpClientpass requests through the parent injector's interceptor chain after its own. Enables scoped interceptors in feature-level providers. -
Angular 20 (2025): Class-based interceptors (
withInterceptorsFromDi) remain supported but documentation recommends functional interceptors exclusively.withFetchdeprecated; Fetch became default, exposing redirect metadata onHttpResponsefor Fetch-aware interceptors. -
Angular 22 (now): Functional interceptors are the standard. All Angular documentation examples use them. Class-based interceptors work via
withInterceptorsFromDi()for migration, but new code should never use them. The interceptor pipeline is fully composable with signals —inject(MySignalService).someSignal()inside an interceptor function reads the signal value at request time.
See also
- HTTP Overview — setup, basic requests, and the request flow diagram that shows where interceptors fit
- Error Handling — catching
HttpErrorResponse, retry strategies, and global error normalization via interceptors - Signals — signal services that interceptors can inject and react to
- Dependency Injection —
why
inject()works inside functional interceptors (injection context) - Official docs — Interceptors
- Official docs — HttpInterceptorFn API