HttpClient is still the basis of communication with the server. What changed: from v21 it’s available without configuration, from v22 it uses the Fetch API by default, interceptors are functions, and for reactive reads you have httpResource. All the HTTP demos on this site run on a backend simulated by an interceptor (core/api/mock-backend.ts).
- v21: HTTP services are provided in root —
inject(HttpClient)works withoutprovideHttpClient(). - v22:
FetchBackendis the default;withFetch()is deprecated; for upload progress:withXhr(). - v22:
reportProgress→reportUploadProgress/reportDownloadProgress. - v22:
httpResourceis stable; TransferCache (SSR) no longer includes requests with credentials by default.
Configuration
// v21: HttpClient is available in root with no configuration at all
export class UsersApi {
private readonly http = inject(HttpClient); // works directly
}
// you call provideHttpClient() only to CONFIGURE it
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor]),
// withXhr(), // v22: Fetch is the default; XHR only if you need upload progress
),
],
};Functional interceptors
// Functional interceptor: a function (req, next) => Observable
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthStore).token();
if (!token || !req.url.startsWith('/api')) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_LOADING)) return next(req); // per-request metadata
const loading = inject(LoadingStore);
loading.start();
return next(req).pipe(finalize(() => loading.stop()));
};
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
// inject() only here, synchronously: inside catchError we are no longer in an injection context
const router = inject(Router);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) router.navigate(['/login']);
return throwError(() => err);
}),
);
};Your componenthttp.get('/api/users')
- Press “Send”: each line is written by the interceptor’s own code, at the moment it runs.
provideHttpClient(
withInterceptors([
authInterceptor,
loggingInterceptor,
retryInterceptor,
cacheInterceptor,
fakeServer, // demo only: the simulated server
]),
)The retry and cache interceptors from the lab, without the log lines:
// Retries only server errors (5xx), twice, with a pause
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET') return next(req); // never repeat a POST automatically
return next(req).pipe(
retry({
count: 2,
delay: (error: HttpErrorResponse) =>
error.status >= 500 ? timer(300) : throwError(() => error),
}),
);
};
// in a service, not a module variable: on the server it would be shared by all users
@Service()
export class ResponseCache {
private readonly entries = new Map<string, HttpResponse<unknown>>();
get(key: string) {
return this.entries.get(key);
}
set(key: string, response: HttpResponse<unknown>) {
this.entries.set(key, response);
}
}
// Keeps GET responses; on a hit, the rest of the chain does not run at all
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET') return next(req);
const cache = inject(ResponseCache);
const hit = cache.get(req.urlWithParams);
if (hit) return of(hit.clone());
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) cache.set(req.urlWithParams, event);
}),
);
};The API layer
Components shouldn’t know URLs or the shape of DTOs. A service per resource hides them and returns domain models (see Interfaces & models).
// data-access/products-api.ts — URLs, DTOs and mapping in one place
@Service()
export class ProductsApi {
private readonly http = inject(HttpClient);
list(q = ''): Observable<Product[]> {
return this.http
.get<PageDto<ProductDto>>('/api/products', { params: { q } })
.pipe(map((page) => page.items.map(toProduct)));
}
get(id: number): Observable<Product> {
return this.http.get<ProductDto>(`/api/products/${id}`).pipe(
map(toProduct),
retry({ count: 2, delay: (_, attempt) => timer(300 * attempt) }), // backoff
);
}
create(body: CreateProductRequest): Observable<Product> {
return this.http.post<ProductDto>('/api/products', body).pipe(map(toProduct));
}
}- Make a request.
Reading vs writing
// Reactive READ → httpResource (in a component or a store)
readonly product = httpResource(() => `/api/products/${this.id()}`, { parse: toProduct });
// WRITE → HttpClient (a command, not a state)
async save() {
const saved = await firstValueFrom(this.api.create(this.form().value()));
this.router.navigate(['/products', saved.id]);
}Changes in v22
// v22: FetchBackend is the default (withFetch() is deprecated — you can remove it)
provideHttpClient(); // uses fetch()
// Upload progress? Fetch doesn't support it → explicit XHR
provideHttpClient(withXhr());
this.http.post('/api/upload', file, { reportUploadProgress: true, observe: 'events' });
// reportProgress is deprecated → reportUploadProgress / reportDownloadProgress
// v22.1: JSONP is deprecated (HttpClient.jsonp, HttpClientJsonpModule)Common pitfalls
- “Cold” requests: an
http.get()withoutsubscribe(orfirstValueFrom) is never sent. - Subscribe inside subscribe for dependent requests → use
switchMapor chained resources (ctx.chain). - Upload with progress after upgrading to v22: Fetch doesn’t report upload progress →
withXhr(). - Interceptors in the wrong order: they apply in array order (auth before logging, etc.).
- Errors handled twice: the interceptor shows a toast, and the component shows another. Decide one place for each kind of error.