Skip to main content

HTTP Client and Communication

Angular includes a built-in HTTP client, eliminating the need to install fetch wrappers or axios like in React.

1. Basic HttpClient Setup​

provideHttpClient() Configuration​

In a standalone setup, add provideHttpClient() to providers in app.config.ts (the legacy HttpClientModule is deprecated).

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './auth.interceptor';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(
withFetch(), // use the fetch API internally (recommended with SSR)
withInterceptors([authInterceptor]),
),
],
};
Feature flagRole
withInterceptors([...])Register functional interceptors
withFetch()Use fetch instead of XHR (recommended for SSR)
withXsrfConfiguration()Configure XSRF tokens
withInterceptorsFromDi()Keep using legacy class-based interceptors

Injecting HttpClient​

Injecting with the inject() function is the current standard. Normally you encapsulate it in a service rather than using it directly in components.

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class TodoApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/todos';
}

2. CRUD Operations​

GET / POST / PUT / DELETE Requests​

export interface Todo {
id: number;
title: string;
done: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/todos';

list(params?: { done?: boolean }): Observable<Todo[]> {
return this.http.get<Todo[]>(this.baseUrl, {
params: params?.done === undefined ? {} : { done: params.done },
});
}

get(id: number): Observable<Todo> {
return this.http.get<Todo>(`${this.baseUrl}/${id}`);
}

create(todo: Omit<Todo, 'id'>): Observable<Todo> {
return this.http.post<Todo>(this.baseUrl, todo);
}

update(todo: Todo): Observable<Todo> {
return this.http.put<Todo>(`${this.baseUrl}/${todo.id}`, todo);
}

remove(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}

Typed Responses​

Specifying a type with generics (get<Todo[]>) types the return value. Note that this is compile-time typing only; no runtime validation takes place. For untrusted APIs, validate with something like Zod. If you need HTTP headers or the status, specify observe: 'response'.

this.http
.get<Todo[]>(this.baseUrl, { observe: 'response' })
.subscribe((res) => {
console.log(res.status, res.headers.get('X-Total-Count'), res.body);
});

Responses as Observables​

HttpClient methods return an Observable, and no request is sent until it is subscribed to (cold). Note also that each subscription issues its own request.

@Component({
selector: 'app-todo-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (todos$ | async; as todos) {
<ul>
@for (t of todos; track t.id) {
<li>{{ t.title }}</li>
}
</ul>
}
`,
})
export class TodoListComponent {
todos$ = inject(TodoApi).list();
}

toSignal is handy when combining with signals.

import { toSignal } from '@angular/core/rxjs-interop';

export class TodoListComponent {
todos = toSignal(inject(TodoApi).list(), { initialValue: [] });
}
@for (t of todos(); track t.id) {
<li>{{ t.title }}</li>
}

Comparison with React fetch / axios / SWR / TanStack Query​

AspectAngular HttpClientReact
ClientBuilt infetch (standard) / axios (external)
Return valueObservable (lazy, cancellable)Promise (eager)
JSON parsingAutomaticfetch needs res.json()
HTTP errors4xx/5xx are treated as errors automaticallyfetch requires checking res.ok yourself
CancellationUnsubscribe / switchMapAbortController
Caching / refetchingNone by default (custom or RxJS)Handled by SWR / TanStack Query
Loading / error stateManaged yourselfProvided by SWR / TanStack Query

The server-state management that SWR and TanStack Query handle (caching, revalidation, loading state) is not part of Angular's HttpClient itself. If needed, adopt @tanstack/angular-query-experimental, or implement it with RxJS and signals.

3. Interceptors​

Interceptors are a mechanism for processing requests and responses across the board.

Functional Interceptors (the New Approach)​

Functional interceptors using HttpInterceptorFn are now recommended and are more concise than the class-based HttpInterceptor. inject() can be used inside the function.

import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
// req is immutable. Make a modified copy with clone()
return next(req.clone({ setHeaders: { 'X-App-Version': '1.0.0' } }));
};

Register with provideHttpClient(withInterceptors([a, b, c])). Requests pass through in array order, and responses pass through in reverse order.

Automatic Auth Token Attachment​

import { inject } from '@angular/core';
import { HttpInterceptorFn } from '@angular/common/http';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();

// Attach the token only to your own API (prevents leaking it to external sites)
if (!token || !req.url.startsWith('/api/')) {
return next(req);
}

return next(
req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }),
);
};

Loading State Management​

Count in-flight requests with a signal and use it for a global loading indicator.

// loading.service.ts
@Injectable({ providedIn: 'root' })
export class LoadingService {
private readonly count = signal(0);
readonly isLoading = computed(() => this.count() > 0);

start(): void { this.count.update((n) => n + 1); }
stop(): void { this.count.update((n) => Math.max(0, n - 1)); }
}

// loading.interceptor.ts
import { finalize } from 'rxjs';

export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loading = inject(LoadingService);
loading.start();
return next(req).pipe(finalize(() => loading.stop()));
};

finalize runs on success, failure, and cancellation alike, so the counter never drifts.

Request/Response Logging​

import { HttpResponse } from '@angular/common/http';
import { tap } from 'rxjs';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const started = performance.now();
return next(req).pipe(
tap({
next: (event) => {
if (event instanceof HttpResponse) {
const ms = Math.round(performance.now() - started);
console.log(`${req.method} ${req.urlWithParams} -> ${event.status} (${ms}ms)`);
}
},
error: (err) => console.error(`${req.method} ${req.urlWithParams} failed`, err),
}),
);
};

Comparison with Next.js Middleware and Axios Interceptors​

AspectAngular InterceptorAxios interceptorsNext.js middleware
Where it runsClient (server during SSR)Client / NodeServer (Edge), before reaching the page
TargetOutgoing HttpClient requestsOutgoing axios requestsIncoming requests
RegistrationwithInterceptors([...])axios.interceptors.request.usemiddleware.ts
UsesToken attachment, common error handlingSameAuth redirects, header manipulation

The role is almost the same as Axios interceptors, but note that Next.js middleware works in the opposite direction, handling incoming requests.

4. Error Handling​

Using the catchError Operator​

import { catchError, of, throwError } from 'rxjs';

list(): Observable<Todo[]> {
return this.http.get<Todo[]>(this.baseUrl).pipe(
// Fall back to an empty array on failure
catchError(() => of([])),
);
}

You can also transform the error and rethrow instead of falling back.

return this.http.get<Todo>(url).pipe(
catchError((err) => throwError(() => new Error('Failed to fetch the todo', { cause: err }))),
);

Handling HttpErrorResponse​

HTTP errors arrive as HttpErrorResponse. A status of 0 indicates that no response was received, such as a network failure or CORS.

import { HttpErrorResponse } from '@angular/common/http';

function toMessage(err: HttpErrorResponse): string {
if (err.status === 0) return 'Cannot connect to the network.';
switch (err.status) {
case 400: return 'There is an error in your input.';
case 401: return 'Authentication is required.';
case 403: return 'You do not have permission.';
case 404: return 'The target was not found.';
default: return err.status >= 500
? 'A server error occurred.'
: 'An unexpected error occurred.';
}
}

Retry Logic (retry, retryWhen)​

retry provides simple retrying. retryWhen is deprecated, so the current way to write exponential backoff is the retry({ delay }) form.

import { retry, timer } from 'rxjs';

this.http.get<Todo[]>(url).pipe(
retry({
count: 3,
delay: (error: HttpErrorResponse, retryCount) => {
// Retry only on 5xx and network errors
if (error.status !== 0 && error.status < 500) {
throw error;
}
return timer(2 ** retryCount * 500); // 1s, 2s, 4s
},
}),
);

Retrying requests with side effects, such as POST, unconditionally risks double execution, so use retries mainly with idempotent GETs.

Global Error Handling​

Consolidate common handling in an interceptor.

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const toast = inject(ToastService);

return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
router.navigate(['/login']);
} else if (err.status >= 500 || err.status === 0) {
toast.error(toMessage(err));
}
return throwError(() => err); // rethrow so callers can handle it too
}),
);
};

Unhandled errors, including non-HTTP ones, are caught in one place by replacing ErrorHandler.

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
// e.g. send to a monitoring service
console.error('Unhandled error', error);
}
}

// Add to providers in app.config.ts
{ provide: ErrorHandler, useClass: GlobalErrorHandler }

5. Caching and Performance​

Caching with shareReplay​

HttpClient does not cache by default. With shareReplay, you can keep the result and reuse it for multiple subscribers.

@Injectable({ providedIn: 'root' })
export class CountryApi {
private readonly http = inject(HttpClient);

readonly countries$ = this.http.get<Country[]>('/api/countries').pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
}

No matter how many times it is subscribed to, the request is sent only the first time. This suits rarely changing data such as master data. To refresh, combine a Subject with switchMap to provide a refetch trigger.

private readonly refresh$ = new BehaviorSubject<void>(undefined);

readonly countries$ = this.refresh$.pipe(
switchMap(() => this.http.get<Country[]>('/api/countries')),
shareReplay(1),
);

reload(): void { this.refresh$.next(); }

Preventing Duplicate Requests​

Subscribing to the same Observable in several places sends that many requests (because it is cold). Besides sharing with shareReplay / share, for things like search input, use switchMap to discard stale requests.

results$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q)), // the previous request is cancelled
);

With SSR, HttpClient responses fetched on the server are automatically transferred to the client, avoiding refetching. You can tune this with provideClientHydration(withHttpTransferCacheOptions(...)).

You can also implement a simple cache that keeps GET responses in a Map inside an interceptor.

const cache = new Map<string, HttpResponse<unknown>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET') return next(req);

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);
}),
);
};

Comparison with React useSWR and TanStack Query Caching​

FeatureAngular (standard)SWR / TanStack Query
CachingshareReplay / customAutomatic, key-based
Deduplicationshare / shareReplayAutomatically merges identical keys
Revalidation (stale-while-revalidate)CustomBuilt in
Refetch on focusCustomBuilt in
RetryretrySpecified via configuration
Loading / error stateCustom (signals, etc.)Returned by the hook

Angular's standard features are a combination of low-level building blocks. For large apps with heavy cache invalidation and revalidation needs, adopting the Angular adapter of TanStack Query is also an option.