Understanding the inner workings of interceptors and HttpClient

The HTTP client introduced in Angular 4.3 brought a significant upgrade to how applications communicate with servers. Among its standout features was request interception, letting developers place custom logic between the application layer and the backend. The official documentation covers the basics of writing and registering interceptors well, but here we’ll take a closer look at what happens behind the scenes inside the HttpClient service and its interception pipeline. Gaining this insight is key to unlocking advanced patterns such as response caching and complex request/response transformations.

We’ll begin by following the documented approach to set up two interceptors that attach custom headers to outgoing requests. After that, we’ll replicate the same behavior by building our own middleware chain, bypassing Angular’s built‑in mechanism. Finally, we’ll explore how HttpClient request methods generate an observable stream of HttpEvents and why immutability is so important in this design.

Working through the examples hands‑on will deepen your understanding far more than reading alone.

The sample app

Let’s start with a straightforward implementation: two interceptors, each responsible for adding a distinct header to the request. Both classes implement the intercept method, where we modify the request to include Custom-Header-1 and Custom-Header-2 respectively:

@Injectable()
export class I1 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-1': '1'}});
        return next.handle(modified);
    }
}

@Injectable()
export class I2 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-2': '2'}});
        return next.handle(modified);
    }
}

Notice that each interceptor receives the next handler as its second parameter. Calling next.handle passes control along the chain; we’ll soon understand why there are scenarios where that call is unnecessary. Also, if you’ve ever pondered why the clone() method is required on a request, the answer lies ahead.

After defining the interceptors, they need to be registered with the HTTP_INTERCEPTORS token:

@NgModule({
    imports: [BrowserModule, HttpClientModule],
    declarations: [AppComponent],
    providers: [
        {
            provide: HTTP_INTERCEPTORS,
            useClass: I1,
            multi: true
        },
        {
            provide: HTTP_INTERCEPTORS,
            useClass: I2,
            multi: true
        }
    ],
    bootstrap: [AppComponent]
})
export class AppModule {}

A simple request will confirm whether the headers were indeed attached:

@Component({
    selector: 'my-app',
    template: `
        <div><h3>Response</h3>{{response|async|json}}</div>
        <button (click)="request()">Make request</button>`
    ,
})
export class AppComponent {
    response: Observable<any>;
    constructor(private http: HttpClient) {}

    request() {
        const url = 'https://jsonplaceholder.typicode.com/posts/1';
        this.response = this.http.get(url, {observe: 'body'});
    }
}

If the setup is correct, the Network tab will show both headers being transmitted to the server:

Insider’s guide into interceptors and HttpClient mechanics in Angular — figure 1

That was straightforward, wasn’t it? The basic version is available here on stackblitz. Now let’s dig into the more engaging details.

Building our own middleware chain

The goal is to integrate interceptors manually into request processing, avoiding the built‑in HttpClient machinery. In doing so, we’ll construct a handler chain that mirrors exactly how Angular operates internally.

Handling a request

Modern browsers support AJAX through either XmlHttpRequest or the Fetch API. Additionally, some libraries rely on JSONP, a technique that can occasionally lead to unexpected change detection behavior. Naturally, Angular provides a service that leverages these browser APIs to communicate with servers. These services are what the documentation calls backends:

In an interceptor, next always represents the next interceptor in the chain, if any, or the final backend if there are no more interceptors

Angular’s HTTP module ships two such backend implementations: HttpXhrBackend, which relies on XmlHttpRequest, and JsonpClientBackend, which uses JSONP. The default choice for HttpClient is HttpXhrBackend.

Angular abstracts the notion of an HTTP handler, which is responsible for processing a request. A middleware chain comprises several such handlers, each passing the request onward until one produces an observable stream. The contract for a handler is defined by the abstract class HttpHandler:

export abstract class HttpHandler {
    abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

A backend service like HttpXhrBackend qualifies as an HTTP handler since it can actually perform a network request. While network communication is the typical form of handling, other possibilities exist—serving a request from a local cache without hitting the server is a common alternative. Therefore, any service that can process a request must implement the handle method, which returns an observable stream of HTTP events like HttpProgressEvent, HttpHeaderResponse, or HttpResponse. Custom request handling logic thus requires implementing the HttpHandler interface.

Using a backend directly as an HTTP handler

The HttpClient service injects a global HTTP handler registered in the DI container under the HttpHandler token, and triggers its handle method to make requests:

export class HttpClient {
    constructor(private handler: HttpHandler) {}
    
    request(...): Observable<any> {
        ...
        const events$: Observable<HttpEvent<any>> = 
            of(req).pipe(concatMap((req: HttpRequest<any>) => this.handler.handle(req)));
        ...
    }
}

By default, that global handler is the HttpXhrBackend, registered in the injector under the HttpBackend token:

@NgModule({
    providers: [
        HttpXhrBackend,
        { provide: HttpBackend, useExisting: HttpXhrBackend }
    ]
})
export class HttpClientModule {}

As you might expect, HttpXhrBackend implements the HttpHandler interface:

export abstract class HttpHandler {
    abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

export abstract class HttpBackend implements HttpHandler {
    abstract handle(req: HttpRequest<any>): Observable<HttpEvent<any>>;
}

export class HttpXhrBackend implements HttpBackend {
    handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {}
}

Because the default XHR backend is registered under HttpBackend, we can inject it ourselves and effectively substitute HttpClient. Instead of calling HttpClient:

export class AppComponent {
    response: Observable<any>;
    constructor(private http: HttpClient) {}

    request() {
        const url = 'https://jsonplaceholder.typicode.com/posts/1';
        this.response = this.http.get(url, {observe: 'body'});
    }
}

let’s invoke the default XHR backend directly, like this:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        this.response = this.backend.handle(req);
    }
}

Check out the demo. A couple of observations: first, we now manually construct the HttpRequest. Second, because the backend handler returns a stream of HTTP events, you’ll see various objects flash on the screen before the full HTTP response is finally rendered.

Incorporating interceptors

We’ve successfully used the backend implementation directly, but the headers are missing because our interceptors never ran. An interceptor alone contains the request‑handling logic; to work with HttpClient, it must be wrapped in a service that implements HttpHandler. That wrapper can execute the interceptor and pass along the reference to the next handler, enabling the interceptor to call the subsequent handler—typically the backend. This design means each custom handler holds a reference to its successor and gives it to the interceptor along with the request. The structure we’re aiming for looks like this:

Insider’s guide into interceptors and HttpClient mechanics in Angular — figure 2

It’s no surprise that Angular already provides such a wrapper: HttpInterceptorHandler. Since it’s not part of the public API, we’ll copy its basic implementation from the source:

export class HttpInterceptorHandler implements HttpHandler {
    constructor(private next: HttpHandler, private interceptor: HttpInterceptor) {}

    handle(req: HttpRequest<any>): Observable<HttpEvent<any>> {
        // execute an interceptor and pass the reference to the next handler
        return this.interceptor.intercept(req, this.next);
    }
}

We can then wrap our first interceptor as follows:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const handler = new HttpInterceptorHandler(this.backend, new I1());
        this.response = handler.handle(req);
    }
}

With a request made this way, the Custom-Header-1 appears. Here’s the demo. Our setup now consists of one HttpInterceptorHandler wrapping an interceptor, and that wrapper references the XHR backend—already a chain of handlers.

Let’s extend the chain by wrapping the second interceptor as well:

export class AppComponent {
    response: Observable<any>;
    constructor(private backend: HttpXhrBackend) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const i1Handler = new HttpInterceptorHandler(this.backend, new I1());
        const i2Handler = new HttpInterceptorHandler(i1Handler, new I2());
        this.response = i2Handler.handle(req);
    }
}

The demo works just like the original sample with HttpClient. What we’ve effectively done is hand‑build a middleware chain of handlers, where each handler executes an interceptor and forwards the next handler reference. Here’s a diagram of that chain:

Insider’s guide into interceptors and HttpClient mechanics in Angular — figure 3

When the statement next.handle(modified) runs inside our interceptor, control moves to the next handler in sequence:

export class I1 implements HttpInterceptor {
    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const modified = req.clone({setHeaders: {'Custom-Header-1': '1'}});
        // passing control to the handler in the chain
        return next.handle(modified);
    }
}

Eventually, the last backend handler issues the actual request to the server.

Automatically wiring interceptors

Instead of manually linking interceptors one by one, we can automate the process by injecting all registered interceptors via the HTTP_INTERCEPTORS token and linking them with reduceRight:

export class AppComponent {
    response: Observable<any>;
    constructor(
        private backend: HttpBackend, 
        @Inject(HTTP_INTERCEPTORS) private interceptors: HttpInterceptor[]) {}

    request() {
        const req = new HttpRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1');
        const i2Handler = this.interceptors.reduceRight(
            (next, interceptor) => new HttpInterceptorHandler(next, interceptor), this.backend);
        this.response = i2Handler.handle(req);
    }
}

Using reduceRight is essential here because it builds the chain beginning with the last interceptor registered. The code above yields the same handler chain we constructed manually. The final value returned by reduceRight is the reference to the very first handler.

Interestingly, the logic I just wrote mirrors Angular’s interceptingHandler function. A note in the source comments explains its purpose:

Constructs an `HttpHandler` that applies a bunch of `HttpInterceptor`s
to a request before passing it to the given `HttpBackend`.
Meant to be used as a factory function within `HttpClientModule`.

Now we understand exactly how it accomplishes that, since we used the same technique to build our chain. The final piece of the puzzle is that this function is registered as the default HttpHandler:

@NgModule({
  providers: [
    {
      provide: HttpHandler,
      useFactory: interceptingHandler,
      deps: [HttpBackend, [@Optional(), @Inject(HTTP_INTERCEPTORS)]],
    }
  ]
})
export class HttpClientModule {}

Thus, the handler chain returned by this function—the first handler in line—is injected and utilized by the HttpClient service.

Building the Observable Stream of the Handler Chain

Now we understand the structure: a collection of handlers, each running its associated interceptor and invoking the subsequent handler in the sequence. The outcome of this chain invocation is an observable stream of HttpEvents. Typically, though not exclusively, this stream is produced by the final handler, which serves as the concrete backend implementation. The other handlers generally pass this stream through unchanged. The closing line in most interceptor implementations looks like this:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    ...
    return next.handle(authReq);
}

Thus, the flow can be visualized as:

Insider’s guide into interceptors and HttpClient mechanics in Angular — figure 4

However, because any interceptor has the ability to return an observable stream of HttpEvents, a wide range of customization becomes available. For instance, you could build your own backend and register it as an interceptor. Alternatively, you might implement a caching strategy that returns a cached value right away when available, bypassing the remaining handlers:

Insider’s guide into interceptors and HttpClient mechanics in Angular — figure 5

Furthermore, every interceptor has access to the observable stream returned by its successor (via next.handler()), enabling modifications to that stream using RxJS operators and custom logic.

Building the Observable Stream of HttpClient

If you've followed the earlier discussion closely, you might be asking whether the HTTP event stream from the handler chain is identical to what HttpClient methods like get or post return. The reality is more nuanced — the actual implementation has greater depth.

When you invoke an HTTP method on HttpClient, it initiates its own observable stream using the request object and the RxJS creation operator of. That stream is what gets returned. The handler chain processes synchronously within this stream, and the observable produced by the chain is flattened using the ****concatMap**** operator. The essential part of the logic lives in the request method, since API methods such as get, post, and delete all delegate to it. The core implementation looks like this:

const events$: Observable<HttpEvent<any>> = of(req).pipe(
    concatMap((req: HttpRequest<any>) => this.handler.handle(req))
);

In place of the older call approach for instance operators, I've used the more modern pipe syntax above. Should you need a refresher on how concatMap functions, check out Learn to combine RxJs sequences with super intuitive interactive diagrams. The rationale for running the handler chain inside this observable stream is laid out in the source comments:

Start with an Observable.of() the initial request, and run the handler (which includes all interceptors) inside a concatMap(). This way, the handler runs inside an Observable chain, which causes interceptors to be re-run on every subscription (this also makes retries re-run the handler, including interceptors).

Dealing with the `observe` request option

The initial observable stream set up by HttpClient emits every HTTP event: HttpProgressEvent, HttpHeaderResponse, HttpResponse, and others. Still, the documentation tells us we can dictate which events matter through the observe option, like so:

request() {
    const url = 'https://jsonplaceholder.typicode.com/posts/1';
    this.response = this.http.get(url, {observe: 'body'});
}

With {observe: 'body'}, the observable from the get method emits only the response's body. Other observe possibilities include events and response, with the latter serving as the default. In my earlier look at the handler chain, I highlighted that its returned stream emits all HTTP events. Filtering those events in line with the observe parameter falls to the HttpClient.

This means the stream implementation I showed in the previous section requires adjustment. Our approach: filter the events and map them to different values based on the observe parameter's value. A slightly condensed version of that implementation appears below:

const events$: Observable<HttpEvent<any>> = of(req).pipe(...)

if (options.observe === 'events') {
    return events$;
}

const res$: Observable<HttpResponse<any>> =
    events$.pipe(filter((event: HttpEvent<any>) => event instanceof HttpResponse));

if (options.observe === 'response') {
    return res$;
}

if (options.observe === 'body') {
    return res$.pipe(map((res: HttpResponse<any>) => res.body));
}

The complete original source is available for reference.

The rationale behind immutability

The documentation contains an intriguing section about immutability, stating:

Interceptors exist to examine and mutate outgoing requests and incoming responses. However, it may be surprising to learn that the HttpRequest and HttpResponse classes are largely immutable. This is for a reason: because the app may retry requests, the interceptor chain may process an individual request multiple times. If requests were mutable, a retried request would be different than the original request. Immutability ensures the interceptors see the same request for each try.

Let me unpack that further. Upon calling any HTTP method on HttpClient, a request object comes into existence. As described earlier, this request kick-starts the observable $events sequence; upon subscription, it moves through the handler chain. Yet the $events stream might undergo a retry, causing the sequence to run again with the original request, which was created outside that sequence. Interceptors, though, should always begin from the original request. If the request were mutable and modified during interceptor execution, that condition would fail on subsequent runs. Given that the same request reference starts the observable multiple times, the request — along with its components like HttpHeaders and HttpParams — must remain immutable.