Angular HTTP interceptors form a pipeline through which every HTTP request and response passes. Each interceptor can modify the request before it is sent, transform the response, handle errors, add side effects (logging, loading state), or retry failed requests. The interceptor order in withInterceptors([...]) determines the wrapping — the first interceptor in the array is the outermost wrapper (runs first on request, last on response).
Complete Interceptor Stack
// ── 1. Logging interceptor ─────────────────────────────────────────────────
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const config = inject(APP_CONFIG);
if (!config.enableDebugTools) return next(req);
const start = Date.now();
console.log(`→ ${req.method} ${req.url}`);
return next(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
const ms = Date.now() - start;
console.log(`← ${event.status} ${req.url} (${ms}ms)`);
}
}),
catchError(err => {
const ms = Date.now() - start;
console.error(`✖ ${err.status} ${req.url} (${ms}ms)`, err.error);
return throwError(() => err);
}),
);
};
// ── 2. Loading interceptor ────────────────────────────────────────────────
// Skip for background requests (e.g., view count increment)
const SKIP_LOADING_HEADER = 'X-Skip-Loading';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loading = inject(LoadingService);
if (req.headers.has(SKIP_LOADING_HEADER)) {
return next(req.clone({ headers: req.headers.delete(SKIP_LOADING_HEADER) }));
}
loading.start();
return next(req).pipe(
finalize(() => loading.stop()),
);
};
// ── 3. Retry interceptor ──────────────────────────────────────────────────
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
// Only retry network errors (status 0), not HTTP errors (4xx/5xx)
return next(req).pipe(
retry({
count: 3,
delay: (error, attempt) => {
if (error.status !== 0) return throwError(() => error); // don't retry HTTP errors
return timer(Math.pow(2, attempt) * 1000); // 2s, 4s, 8s
},
}),
);
};
// ── 4. Cache interceptor ──────────────────────────────────────────────────
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Only cache GET requests without auth requirements
if (req.method !== 'GET' || req.headers.has('Authorization')) return next(req);
const cached = cache.get(req.urlWithParams);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS)
return of(new HttpResponse({ body: cached.data, status: 200 }));
return next(req).pipe(
tap(event => {
if (event instanceof HttpResponse && event.status === 200)
cache.set(req.urlWithParams, { data: event.body, timestamp: Date.now() });
}),
);
};
// ── Interceptor order in app.config.ts ────────────────────────────────────
// Outermost first (processes request first, response last)
provideHttpClient(withInterceptors([
loggingInterceptor, // 1. outer: logs first and last
loadingInterceptor, // 2. tracks all requests
retryInterceptor, // 3. retries before auth/error handling
authInterceptor, // 4. adds token, handles 401/refresh
errorInterceptor, // 5. inner: translates HTTP errors to ApiError
]))
X-Skip-Loading header pattern allows individual requests to opt out of the global loading state. Fire-and-forget calls (view count increment, analytics pings) should not show a loading spinner — they run silently in the background. Add the header when making the request: http.post(url, body, { headers: { 'X-Skip-Loading': 'true' } }). The loading interceptor strips the header before forwarding the request so it never reaches the server.status === 0 errors (network errors — no response received) rather than HTTP errors (4xx/5xx — server responded). Retrying a 404 or 500 is counterproductive — the server is responding correctly, retrying will produce the same response. Retrying a status 0 makes sense — the request may not have reached the server at all due to a transient network blip. Always check the error status before retrying.HttpClient with browser’s native HTTP caching (Cache-Control headers from the API) rather than an in-memory interceptor. The native cache is more correct and respects the API’s stated cache policy.Interceptor Execution Order
| Interceptor | Request Phase | Response Phase |
|---|---|---|
| loggingInterceptor | 1st — logs “→ GET /api/posts” | 5th — logs “← 200 (45ms)” |
| loadingInterceptor | 2nd — loading.start() | 4th — loading.stop() (finalize) |
| retryInterceptor | 3rd | 3rd — retries on network error |
| authInterceptor | 4th — adds Bearer token | 2nd — handles 401/refresh |
| errorInterceptor | 5th — inner, closest to HTTP | 1st — first to see response |
Common Mistakes
Mistake 1 — Retrying HTTP errors (4xx/5xx) instead of only network errors (429 hammering)
❌ Wrong — retry on all errors; a 429 Too Many Requests is retried 3 times immediately; rate limit hit gets worse.
✅ Correct — only retry status 0 (network errors); use specific delay for 429 if needed.
Mistake 2 — Wrong interceptor order (auth interceptor outside retry — retried with wrong/missing token)
❌ Wrong — authInterceptor before retryInterceptor; retry sends request without token if auth interceptor fails.
✅ Correct — retryInterceptor wraps authInterceptor; each retry attempt goes through auth to get fresh token.