TL;DR — Prefer functional interceptors in Angular 20. Configure them with
provideHttpClient(withInterceptors([...])). Use them to add auth headers, cache responses, log timings, retry with backoff, stream progress events, and even return synthetic responses. Lean onHttpContextTokenfor per-request metadata andAbortSignal/redirect info when usingwithFetch.
Why interceptors?
Interceptors are middleware for HttpClient. You can register several and they form a chain; each can:
- Modify requests (e.g., add headers, set timeouts).
- Observe/transform responses (e.g., logging, metrics).
- Short‑circuit the chain with synthetic responses (e.g., cache hits).
- Coordinate UI (spinners) and retry logic.
Angular 20 supports:
- Functional interceptors (recommended)
- DI-based interceptors (classic class style)
1) Getting started — functional interceptors
// app.config.ts (or main.ts)
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
export function loggingInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
console.log('[HTTP]', req.method, req.url);
return next(req);
}
export function authInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const token = inject(AuthService).token();
const cloned = req.clone({
headers: req.headers.append('Authorization', `Bearer ${token}`)
});
return next(cloned);
}
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([loggingInterceptor, authInterceptor]))
]
};
Order matters: loggingInterceptor runs before authInterceptor on the way out, and after it on the way back in.
2) Intercepting response events
You can pipe the response event stream and inspect HttpEventType to handle progress, headers, and the final response.
export function metricsInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const t0 = performance.now();
return next(req).pipe(tap(event => {
if (event.type === HttpEventType.Response) {
const ms = Math.round(performance.now() - t0);
console.info(`[HTTP] ${req.method} ${req.url} → ${event.status} in ${ms}ms`);
}
}));
}
3) Modifying requests (immutability & cloning)
HttpRequest is immutable. Use req.clone(...) to add/replace headers, params, or set other options:
export function jsonInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const withJson = req.clone({
setHeaders: { 'Accept': 'application/json' },
responseType: 'json'
});
return next(withJson);
}
⚠️ The body is not deeply protected — avoid mutating the same object instance if the request can be retried.
4) Per-request metadata with HttpContextToken
Use context tokens to pass flags or config for particular requests, without polluting headers.
// tokens.ts
export const CACHING_ENABLED = new HttpContextToken<boolean>(() => true);
export const DEADLINE_MS = new HttpContextToken<number>(() => 0);
Read tokens in an interceptor:
export function cachingInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const enabled = req.context.get(CACHING_ENABLED);
if (!enabled) return next(req);
// ... perform cache lookup and optionally return synthetic response
return next(req);
}
Set tokens when making a request:
http.get('/api/items', {
context: new HttpContext().set(CACHING_ENABLED, false).set(DEADLINE_MS, 2500)
});
HttpContextis mutable across retries — useful for carrying state between attempts.
5) Synthetic responses (cache / fallbacks)
Interceptors can skip next and answer immediately — perfect for cache hits or offline fallbacks:
export function memoryCacheInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const cache = inject(CacheService);
const hit = cache.get(req);
if (hit) {
return of(new HttpResponse({ status: 200, body: hit }));
}
return next(req).pipe(tap(event => {
if (event.type === HttpEventType.Response) cache.put(req, event.body);
}));
}
6) Timeouts, retries, and backoff
export function robustInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const deadline = req.context.get(DEADLINE_MS);
const base = 250; // ms
return next(req).pipe(
(source) => deadline > 0 ? source.pipe(timeout({ each: deadline })) : source,
retry({
count: 3,
delay: (err, retryIndex) => timer(base * Math.pow(2, retryIndex))
})
);
}
Keep interceptors idempotent since a retried request re-enters the chain.
7) Redirect information with withFetch
When configured with withFetch, Angular exposes native Fetch redirected data on responses. You can observe and act on it:
export function redirectTrackingInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
return next(req).pipe(tap(event => {
if (event.type === HttpEventType.Response && event.redirected) {
console.debug('Redirected:', req.url, '→', event.url);
}
}));
}
8) Loading spinners and UI coordination
A tiny in-flight counter drives a global spinner with Signals:
@Injectable({ providedIn: 'root' })
export class HttpUiService {
private _count = signal(0);
readonly active = computed(() => this._count() > 0);
inc() { this._count.update(n => n + 1); }
dec() { this._count.update(n => Math.max(0, n - 1)); }
}
export function spinnerInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const ui = inject(HttpUiService);
ui.inc();
return next(req).pipe(finalize(() => ui.dec()));
}
In a component template:
<div class="fixed inset-x-0 top-0 flex justify-center" *ngIf="ui.active()">
<span class="loading loading-spinner loading-md mt-3"></span>
</div>
9) Full example — assembling the chain
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
loggingInterceptor,
jsonInterceptor,
spinnerInterceptor,
authInterceptor,
cachingInterceptor,
robustInterceptor,
redirectTrackingInterceptor,
])
),
],
};
The order above ensures:
- We log the exact outgoing request.
- Enforce JSON expectations early.
- Show spinner during all network work.
- Attach auth headers before hitting cache/write logic.
- Apply cache/retry last to observe the final form of the request.
10) DI-based interceptors (for legacy setups)
@Injectable()
export class LoggingInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, handler: HttpHandler): Observable<HttpEvent<any>> {
console.log('Request URL:', req.url);
return handler.handle(req);
}
}
// Enable DI-based interceptors:
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
]
});
Functional vs DI-based: capabilities are the same — functional interceptors win on predictability and tree-shaking.
Pro tips & pitfalls
- Prefer functional form; keep each interceptor focused and side‑effect free.
- Never mutate request/response bodies in place when retries are possible.
- Use context tokens for per-request flags (caching, deadlines, tracing).
- Use
AbortSignal(available under the hood) and timeouts to avoid zombie requests. - Cache only GETs and consider cache invalidation on mutations.
- For metrics, prefer
performance.now()and record both status and duration.
Copy‑paste starter (auth + spinner + metrics)
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([metricsInterceptor, spinnerInterceptor, authInterceptor]))
]
};
Happy intercepting! If you want, I can add unit tests for these interceptors (using HttpClientTestingModule) and a demo page that toggles context flags at runtime.

Top comments (0)