DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Angular 20: HttpClient Interceptors — Functional, Predictable, and Powerful

Angular 20: HttpClient Interceptors — Functional, Predictable, and Powerful

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 on HttpContextToken for per-request metadata and AbortSignal/redirect info when using withFetch.

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]))
  ]
};
Enter fullscreen mode Exit fullscreen mode

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`);
    }
  }));
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Set tokens when making a request:

http.get('/api/items', {
  context: new HttpContext().set(CACHING_ENABLED, false).set(DEADLINE_MS, 2500)
});
Enter fullscreen mode Exit fullscreen mode

HttpContext is 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);
  }));
}
Enter fullscreen mode Exit fullscreen mode

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))
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }));
}
Enter fullscreen mode Exit fullscreen mode

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()));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

9) Full example — assembling the chain

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        loggingInterceptor,
        jsonInterceptor,
        spinnerInterceptor,
        authInterceptor,
        cachingInterceptor,
        robustInterceptor,
        redirectTrackingInterceptor,
      ])
    ),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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 },
  ]
});
Enter fullscreen mode Exit fullscreen mode

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]))
  ]
};
Enter fullscreen mode Exit fullscreen mode

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)