skip to content

In Angular, given provideHttpClient(withInterceptors([a, b, c])), in what order do the interceptors see the request and the response, and why does placement matter?

level: middleimportance: should knowfreq 48%

answer

  1. layers of an onion
  2. list order going out
  3. reverse order coming back
  4. who sees whose headers

basics

~20 s

The request passes a, then b, then c, then the backend; the response and errors travel back through c, b, then a. So a sees the final outcome last, and each interceptor sees only request changes made by those listed before it.

solid answer

~50 s

Interceptors form a chain in the order listed in `withInterceptors([...])`: the outgoing request goes `a → b → c → backend`, and because each interceptor wraps the Observable returned by `next()`, responses and errors come back `c → b → a`. That makes placement a design decision. The **outermost** interceptor (`a`) sees the final result after inner ones retried, cached or mapped it, so logging, timing and error reporting belong there. An interceptor sees only headers added by the interceptors **before** it, so a logger placed ahead of the auth interceptor never sees the `Authorization` header. An interceptor that answers from a cache without calling `next()` skips everything **after** it. Class-based interceptors registered through `HTTP_INTERCEPTORS` run where `withInterceptorsFromDi()` sits among the features, in provider registration order, which the Angular guide warns is hard to predict in a hierarchical DI setup.

code

ts · 27 lines
ts
import { ApplicationConfig } from '@angular/core';
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { finalize } from 'rxjs';

export const timingInterceptor: HttpInterceptorFn = (req, next) => {
  const started = performance.now();
  // Runs when the response or final error comes back through this layer.
  return next(req).pipe(
    finalize(() => console.debug(`${req.method} ${req.url}: ${Math.round(performance.now() - started)} ms`)),
  );
};

export const headerLogger: HttpInterceptorFn = (req, next) => {
  // Sees Authorization only if placed AFTER the auth interceptor.
  console.debug('has auth header:', req.headers.has('Authorization'));
  return next(req);
};

export const authInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { Authorization: 'Bearer <token>' } }));

export const appConfig: ApplicationConfig = {
  providers: [
    // Request: timing -> auth -> headerLogger -> backend. Response: headerLogger -> auth -> timing.
    provideHttpClient(withInterceptors([timingInterceptor, authInterceptor, headerLogger])),
  ],
};

go deeper

for a junior

Remember that the array order in withInterceptors is the order the request passes through, and the response comes back in reverse.

for a middle

Explain the onion model from next(req).pipe(...), and place logging, retry, auth and error handling deliberately using it.

for a senior

Reason about short-circuiting caches, retries that resubscribe downstream, and mixed functional and class chains during a migration.

for a principal

Keep one explicit, reviewed interceptor list for the app, and decide which cross-cutting concerns belong in interceptors at all versus in services or the server.

## The chain is built from the list `HttpClient` hands each request to an **interceptor chain** and then to the **backend** (the component that performs the network call — `FetchBackend` by default since Angular 22). With ```ts provideHttpClient(withInterceptors([a, b, c])) ``` the Angular guide states that interceptors run in the order listed. In the v22 source the chain is built with `reduceRight`, so that `[a, b, c]` executes left to right: `a` receives the request and its `next()` calls `b`, whose `next()` calls `c`, whose `next()` calls the backend. Angular's own XSRF interceptor is registered by `provideHttpClient()` itself, ahead of the features, so it runs before yours. ## Why the response comes back reversed Each interceptor returns the Observable produced by `next()`, usually with operators added: `next(req).pipe(...)`. The operators of `c` are closest to the backend, so they see the response first; `b`'s operators see what `c` passed on; `a`'s operators see what `b` passed on. It is an onion: | Phase | Order | What each interceptor sees | |---|---|---| | Request out | `a → b → c → backend` | The request as modified by the interceptors listed before it | | Response or error back | `backend → c → b → a` | The result as transformed by the interceptors listed after it | ## Placement rules that follow 1. **Error reporting and timing go first (outermost).** Only the first interceptor sees the final outcome after inner retries, caches and mappings. A timer placed first measures the total user-visible time, including any retries made by inner interceptors. 2. **Retry goes inside error reporting**, so a failure that a retry recovers from is never reported. 3. **Header-adding interceptors go before anything that must see the header.** A logger listed ahead of the auth interceptor logs requests without `Authorization`. 4. **A caching interceptor short-circuits what follows.** If it returns a stored `HttpResponse` without calling `next()`, interceptors listed after it do not run for that request; those before it still do. 5. **An interceptor may call `next()` more than once or not at all.** Refresh-and-replay and synthetic responses rely on this. ## Class-based interceptors in the same chain Older code defines interceptors as classes implementing `HttpInterceptor` and registers them with the `HTTP_INTERCEPTORS` multi-provider. They join the chain only when `withInterceptorsFromDi()` is part of `provideHttpClient(...)`: - The whole group of class interceptors runs at the position where `withInterceptorsFromDi()` appears among the features, relative to `withInterceptors([...])`. - Inside the group, they run in the order their providers were registered. - The guide warns that in an app with an extensive, hierarchical DI configuration that order can be very hard to predict, and recommends functional interceptors; the `withInterceptorsFromDi()` API documentation adds that support for DI-provided interceptors may be phased out in a later release. ## A worked example For an app with a bearer token, a retry policy and a central error handler: ```ts provideHttpClient(withInterceptors([errorInterceptor, timingInterceptor, retryInterceptor, authInterceptor])) ``` - `errorInterceptor` reports only failures that survived every retry. - `timingInterceptor` measures the user-visible duration, including retries. - `retryInterceptor` resubscribes to the Observable returned by `next()`, which re-sends the request prepared downstream. - `authInterceptor` is innermost, so it can also handle a 401 itself before any outer interceptor sees it. One subtlety of that last point: a retry resubscribes to the same downstream Observable. Code that `authInterceptor` ran when `next()` was first called — such as reading the token and cloning the request — is not run again, so each retry sends the header computed on the first attempt. That is fine for transient 5xx or network errors; token expiry is handled by the auth interceptor itself. ## Mistakes to avoid - Assuming response handlers run in the same order as request handlers. - Putting error reporting innermost, so it reports failures that a retry later fixed. - Mixing functional and class interceptors without checking where `withInterceptorsFromDi()` sits.

  • An Angular app has both withInterceptors([authInterceptor]) and class interceptors registered through HTTP_INTERCEPTORS. Which run first?
    It depends on the order of the features in `provideHttpClient(...)`: the class interceptors run as one group at the position where `withInterceptorsFromDi()` appears, so `provideHttpClient(withInterceptors([authInterceptor]), withInterceptorsFromDi())` runs `authInterceptor` first. Within the class group, provider registration order applies. Migrating the classes to functions makes the whole order visible in one array.
  • Why does a retry interceptor placed before the auth interceptor not re-read the token on each attempt?
    The retry operator resubscribes to the Observable that `next(req)` returned. The auth interceptor's body — reading the token and cloning the request — ran once, when `next()` was called, and produced that Observable with the header already set. Resubscribing only repeats the downstream subscription work, so every retry carries the first attempt's header. Re-reading per attempt needs the auth logic deferred to subscription time, or the refresh handled inside the auth interceptor.

The chain is a row of nested doors: a request walks out through door a, then b, then c to reach the server, and the reply walks back in through c, then b, then a. The first door is the last one the answer passes.

saying these in an interview costs you the question

  • Responses pass through interceptors in the same order as requests.
  • Every interceptor sees the headers added by every other interceptor.
  • An interceptor that returns a cached response still lets later interceptors run.
  • Class interceptors in HTTP_INTERCEPTORS always run after all functional interceptors.
  • The last interceptor in the list is the right place for global error reporting.