HTTP, interceptors and async resources

Configure provideHttpClient, write functional interceptors for auth and retries, and use resource and httpResource to get loading and error states without boilerplate.

provideHttpClient and typed calls

// app.config.ts
import { provideHttpClient, withFetch, withInterceptors, withRequestsMadeViaParent } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),                                  // use fetch instead of XHR
      withInterceptors([authInterceptor, retryInterceptor, loggingInterceptor])
    )
  ]
};
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry, timeout } from 'rxjs/operators';

export interface Invoice { id: string; total: number; status: 'draft' | 'sent' | 'paid'; }
export interface Page<T> { items: T[]; total: number; cursor: string | null; }

@Injectable({ providedIn: 'root' })
export class InvoiceApi {
  private readonly http = inject(HttpClient);
  private readonly base = '/api/v1';

  list(params: { cursor?: string; status?: Invoice['status']; limit?: number }): Observable<Page<Invoice>> {
    let httpParams = new HttpParams().set('limit', params.limit ?? 20);
    if (params.cursor) httpParams = httpParams.set('cursor', params.cursor);
    if (params.status) httpParams = httpParams.set('status', params.status);

    return this.http.get<Page<Invoice>>(`${this.base}/invoices`, { params: httpParams }).pipe(
      timeout(10_000),
      retry({ count: 2, delay: 500 }),
      catchError((error: HttpErrorResponse) => {
        if (error.status === 0) return throwError(() => new Error('Network unavailable'));
        return throwError(() => error);
      })
    );
  }

  // HttpClient parses JSON by default; ask for text when the body is not JSON
  downloadCsv(): Observable<string> {
    return this.http.get(`${this.base}/invoices.csv`, { responseType: 'text' });
  }
}
OptionEffectWhen it matters
responseTypejson (default), text, blob, arraybufferFile downloads and plain-text APIs
observe: 'response'Emits the full HttpResponseReading headers or the status code
reportProgressProgress events for uploadsLong file uploads with a progress bar
contextPer-request metadataOpt a request out of an interceptor
withCredentialsSends cookies cross-originCookie-based auth on another origin
💡
The typed generic on http.get<T> is a promise about shape, not a runtime check. The server is the only authority on what arrives, so validate at the boundary if the data drives anything important — a schema check or a narrow guard, not more interfaces.

Functional interceptors

// core/auth.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';

export const authInterceptor: HttpInterceptorFn = (request, next) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  // Skip public endpoints rather than sending a useless header.
  if (request.url.startsWith('/public/')) return next(request);

  const token = auth.accessToken();          // a signal, read on each request
  const authorised = token
    ? request.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
    : request;

  return next(authorised).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status !== 401) return throwError(() => error);

      // One refresh attempt, then hand the original request back through.
      return auth.refresh().pipe(
        switchMap(() => next(request.clone({
          setHeaders: { Authorization: `Bearer ${auth.accessToken()!}` }
        }))),
        catchError(() => {
          auth.clear();
          void router.navigate(['/login'], { queryParams: { redirect: router.url } });
          return throwError(() => error);
        })
      );
    })
  );
};
// core/retry.interceptor.ts — retry only what is safe to repeat
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { retry, timer } from 'rxjs';

const RETRYABLE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

export const retryInterceptor: HttpInterceptorFn = (request, next) => {
  if (!RETRYABLE_METHODS.has(request.method)) return next(request);

  return next(request).pipe(
    retry({
      count: 2,
      delay: (error, attempt) => {
        // Do not retry client errors; a 404 will be a 404 again.
        if (error instanceof HttpErrorResponse && error.status < 500) throw error;
        return timer(300 * 2 ** (attempt - 1));       // 300ms, 600ms
      }
    })
  );
};
Interceptor jobReadsCareful about
Add an auth headerA token signal or serviceNever attach to third-party URLs
Refresh on 401A refresh callOnly one refresh in flight, or you get a storm
Retry transient failuresStatus code and methodNever retry a non-idempotent POST by default
Show a global loaderA request counterStreaming responses never emit complete
Log and correlateA request id headerDo not log bodies with personal data
Rewrite a URL for SSRThe platformServer-side requests need absolute URLs

Interceptors run in registration order on the way out and in reverse order on the way back, so the array order is meaningful. Put authentication first, retries last, and use HttpContextToken when a single request must opt out — a far cleaner mechanism than checking the URL.

resource and httpResource

import { Component, signal, computed, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-invoice-list',
  template: `
    <select [value]="status()" (change)="status.set($any($event.target).value)">
      <option value="draft">Draft</option>
      <option value="sent">Sent</option>
      <option value="paid">Paid</option>
    </select>

    @if (invoices.isLoading()) {
      <p role="status">Loading…</p>
    } @else if (invoices.error()) {
      <p role="alert">Could not load invoices.</p>
      <button (click)="invoices.reload()">Try again</button>
    } @else {
      <p>{{ count() }} invoices</p>
    }
  `
})
export class InvoiceListComponent {
  readonly status = signal<'draft' | 'sent' | 'paid'>('draft');

  // Re-fetches automatically whenever a signal read inside the request changes.
  readonly invoices = httpResource<Page<Invoice>>(() => ({
    url: '/api/v1/invoices',
    params: { status: this.status() }
  }), { defaultValue: { items: [], total: 0, cursor: null } });

  readonly count = computed(() => this.invoices.value().total);
}
// For non-HTTP async work, resource() gives the same shape.
import { resource } from '@angular/core';

readonly stats = resource({
  params: () => ({ from: this.from(), to: this.to() }),
  loader: async ({ params, abortSignal }) => {
    // abortSignal is wired up for you: the previous load is cancelled
    // when params change or the component is destroyed.
    const res = await fetch(`/api/stats?from=${params.from}&to=${params.to}`, { signal: abortSignal });
    if (!res.ok) throw new Error(`Stats failed: ${res.status}`);
    return res.json() as Promise<Stats>;
  }
});

// stats.value()      -> Stats | undefined
// stats.isLoading()  -> boolean
// stats.error()      -> unknown | undefined
// stats.reload()     -> sets a new loading cycle
// stats.status()     -> 'idle' | 'loading' | 'reloading' | 'error' | 'resolved' | ...
  • A resource owns the whole request lifecycle: dependencies, cancellation, loading, error and reload. That is the boilerplate you would otherwise write with switchMap.
  • Changing a signal read inside params triggers a reload; the previous in-flight request is aborted rather than left to complete.
  • defaultValue is what makes value() non-optional, which keeps the template free of nesting for the common list case.
  • reload() is the retry button. There is no need to keep a refreshTrigger signal purely to force a refetch.

FAQ

Why does my interceptor not run?
Interceptors registered with withInterceptors in a component-level provider do not automatically apply to the root HttpClient. Either register them in the root provider array or add withRequestsMadeViaParent() so child injectors chain to the parent's client.
resource or a plain service call?
Use resource when the result is bound to a template and you want loading and error states for free. Use a plain service method when the call is an action rather than a view of state — a POST that creates something, a delete, a fire-and-forget log.

Services and dependency injection RxJS essentials for Angular

Last refreshed 2026-09-18.