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' });
}
}| Option | Effect | When it matters |
|---|---|---|
responseType | json (default), text, blob, arraybuffer | File downloads and plain-text APIs |
observe: 'response' | Emits the full HttpResponse | Reading headers or the status code |
reportProgress | Progress events for uploads | Long file uploads with a progress bar |
context | Per-request metadata | Opt a request out of an interceptor |
withCredentials | Sends cookies cross-origin | Cookie-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 job | Reads | Careful about |
|---|---|---|
| Add an auth header | A token signal or service | Never attach to third-party URLs |
| Refresh on 401 | A refresh call | Only one refresh in flight, or you get a storm |
| Retry transient failures | Status code and method | Never retry a non-idempotent POST by default |
| Show a global loader | A request counter | Streaming responses never emit complete |
| Log and correlate | A request id header | Do not log bodies with personal data |
| Rewrite a URL for SSR | The platform | Server-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
paramstriggers a reload; the previous in-flight request is aborted rather than left to complete. defaultValueis what makesvalue()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 arefreshTriggersignal 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.Related
Services and dependency injection RxJS essentials for Angular
Last refreshed 2026-09-18.