tation follows a strict composition pattern: define functional interceptors, inject dependencies contextually, chain operations explicitly, and guard platform-specific logic.
Step 1: Replace Module Providers with Application Configuration
Modern Angular applications initialize networking through ApplicationConfig. The provideHttpClient() function accepts composable providers that configure the underlying HttpClient instance. Interceptors are registered via withInterceptors(), which accepts an array of HttpInterceptorFn functions.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { requestSignatureMiddleware } from './interceptors/request-signature.middleware';
import { networkRetryHandler } from './interceptors/network-retry.handler';
import { telemetryDispatcher } from './interceptors/telemetry.dispatcher';
import { responseCacheOrchestrator } from './interceptors/response-cache.orchestrator';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
requestSignatureMiddleware,
networkRetryHandler,
telemetryDispatcher,
responseCacheOrchestrator
])
)
]
};
The array sequence dictates execution order. Requests flow from left to right; responses flow from right to left. This explicit topology replaces the implicit multi: true registration pattern.
Step 2: Define Functional Interceptors with Contextual DI
Functional interceptors implement the HttpInterceptorFn signature. Dependencies are resolved using inject(), which operates within the current injection context. This eliminates the need for class constructors and enables pure function testing.
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { inject } from '@angular/core';
import { SignatureService } from '../services/signature.service';
import { Observable } from 'rxjs';
export const requestSignatureMiddleware: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const signatureProvider = inject(SignatureService);
const signedHeaders = signatureProvider.generate(request.url, request.method);
const mutatedRequest = request.clone({
setHeaders: signedHeaders
});
return next(mutatedRequest);
};
The clone() method creates an immutable copy of the request with modified headers. The original request object remains untouched, preserving pipeline purity. next() passes the mutated request to the subsequent stage.
Interceptors can intercept both outgoing requests and incoming responses. By returning an Observable, you can apply operators like retry, timeout, catchError, or tap to the response stream.
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { retry, delay, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { inject } from '@angular/core';
import { RetryPolicyService } from '../services/retry-policy.service';
export const networkRetryHandler: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
) => {
const policy = inject(RetryPolicyService);
return next(request).pipe(
retry({
count: policy.maxAttempts,
delay: (error, retryCount) => {
if (!policy.isRetriable(error)) {
return throwError(() => error);
}
return delay(policy.calculateBackoff(retryCount));
}
}),
catchError((err) => {
policy.logExhaustedRetries(request.url, err);
return throwError(() => err);
})
);
};
This pattern isolates retry logic from authentication or caching concerns. The delay operator receives the error and retry count, enabling exponential backoff calculations. catchError handles terminal failures after exhaustion.
Server-side rendering requires explicit environment checks. Interceptors that interact with browser APIs must short-circuit during SSR execution.
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AnalyticsService } from '../services/analytics.service';
export const telemetryDispatcher: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
const platformId = inject(PLATFORM_ID);
const analytics = inject(AnalyticsService);
if (isPlatformServer(platformId)) {
return next(request);
}
return next(request).pipe(
tap({
next: () => analytics.trackRequestSuccess(request.url),
error: (err) => analytics.trackRequestFailure(request.url, err)
})
);
};
The isPlatformServer() guard prevents window or localStorage access during SSR. Telemetry dispatch only executes in browser contexts, eliminating hydration mismatches.
Architecture Rationale
Each decision in this pipeline serves a specific architectural goal:
- Explicit array ordering eliminates hidden execution dependencies and enables visual traceability.
- Functional interceptors remove class instantiation overhead and enable pure function testing.
inject() context resolves dependencies without constructor injection, aligning with Angular's standalone component model.
- Immutable request cloning prevents cross-contamination between pipeline stages.
- Platform guards isolate browser-specific logic, ensuring SSR compatibility.
- RxJS composition enables declarative response transformation without mutating the underlying HTTP handler.
Pitfall Guide
1. Implicit Execution Order
Explanation: Relying on HTTP_INTERCEPTORS multi-provider registration creates an invisible execution stack. Provider order depends on module import sequence, which changes during lazy loading or library upgrades.
Fix: Migrate to withInterceptors([...]) and document the array sequence. Treat the array as a version-controlled pipeline contract.
2. Unconditional Browser API Access
Explanation: Interceptors that call window, document, or localStorage without platform checks crash during SSR hydration. The server environment lacks these globals.
Fix: Wrap browser-specific logic in isPlatformServer(inject(PLATFORM_ID)) guards. Return next(request) immediately when running on the server.
3. Over-Cloning Requests
Explanation: Calling request.clone() on every request, even when no modifications are needed, creates unnecessary object allocations and degrades throughput under high concurrency.
Fix: Clone only when headers, body, or params change. Use conditional cloning: const finalReq = needsMutation ? request.clone({ ... }) : request;
4. Testing with TestBed
Explanation: Legacy interceptors require TestBed.configureTestingModule() to resolve dependencies, adding compilation overhead and masking pure logic bugs.
Fix: Test functional interceptors as pure functions. Mock HttpHandlerFn to return controlled observables. Verify mutation and stream behavior without Angular's compilation layer.
5. Circular Dependency Injection
Explanation: Interceptors that inject services which themselves depend on HttpClient create circular references. Angular's DI resolver throws NullInjectorError or Maximum call stack size exceeded.
Fix: Break the cycle by injecting Injector and using inject() lazily, or restructure the service to accept an HttpClient instance via factory provider. Avoid injecting HttpClient directly into interceptor dependencies.
6. Ignoring HttpEventType
Explanation: Interceptors that apply operators to the entire response stream may interfere with upload progress events, response headers, or chunked data. Operators like map or tap execute on every event type.
Fix: Filter by event type before transformation: filter(event => event.type === HttpEventType.Response). Apply operators only to terminal response events.
7. Blocking Error Boundaries
Explanation: Catching errors in one interceptor and returning a fallback response prevents downstream interceptors from handling the failure. Error propagation becomes inconsistent across the pipeline.
Fix: Use catchError to transform errors, not suppress them. Re-throw transformed errors using throwError() to maintain pipeline integrity. Let terminal error handlers (e.g., global error boundary) manage final fallbacks.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple header injection | Functional interceptor with inject() | Lightweight, tree-shakeable, testable | Low |
| Complex retry/backoff logic | Functional interceptor with RxJS retry() | Declarative, composable, no class overhead | Low |
| SSR-compatible telemetry | Platform-guarded functional interceptor | Prevents hydration crashes, isolates browser APIs | Medium |
| Legacy module-based app | Gradual migration via provideHttpClient() | Maintains compatibility while modernizing pipeline | Medium |
| High-concurrency API gateway | Functional interceptor with request deduplication | Reduces redundant network calls, improves throughput | High |
Configuration Template
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
import { requestSignatureMiddleware } from './interceptors/request-signature.middleware';
import { networkRetryHandler } from './interceptors/network-retry.handler';
import { telemetryDispatcher } from './interceptors/telemetry.dispatcher';
import { responseCacheOrchestrator } from './interceptors/response-cache.orchestrator';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(),
withInterceptors([
requestSignatureMiddleware,
networkRetryHandler,
telemetryDispatcher,
responseCacheOrchestrator
])
)
]
};
Quick Start Guide
- Initialize the provider: Replace
HTTP_INTERCEPTORS multi-providers with provideHttpClient(withInterceptors([])) in your ApplicationConfig.
- Create your first functional interceptor: Define a
HttpInterceptorFn that uses inject() for dependencies and returns next(request).
- Add platform guards: Wrap browser-specific logic in
isPlatformServer() checks to ensure SSR compatibility.
- Compose the pipeline: Arrange interceptors in the
withInterceptors array according to execution priority. Test the chain with mock HttpHandlerFn implementations.
- Validate bundle impact: Run
ng build --configuration production and compare bundle sizes. Unused interceptors should be eliminated by the tree-shaker.