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 flag | Role |
|---|---|
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
| Aspect | Angular HttpClient | React |
|---|---|---|
| Client | Built in | fetch (standard) / axios (external) |
| Return value | Observable (lazy, cancellable) | Promise (eager) |
| JSON parsing | Automatic | fetch needs res.json() |
| HTTP errors | 4xx/5xx are treated as errors automatically | fetch requires checking res.ok yourself |
| Cancellation | Unsubscribe / switchMap | AbortController |
| Caching / refetching | None by default (custom or RxJS) | Handled by SWR / TanStack Query |
| Loading / error state | Managed yourself | Provided 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
| Aspect | Angular Interceptor | Axios interceptors | Next.js middleware |
|---|---|---|---|
| Where it runs | Client (server during SSR) | Client / Node | Server (Edge), before reaching the page |
| Target | Outgoing HttpClient requests | Outgoing axios requests | Incoming requests |
| Registration | withInterceptors([...]) | axios.interceptors.request.use | middleware.ts |
| Uses | Token attachment, common error handling | Same | Auth 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
| Feature | Angular (standard) | SWR / TanStack Query |
|---|---|---|
| Caching | shareReplay / custom | Automatic, key-based |
| Deduplication | share / shareReplay | Automatically merges identical keys |
| Revalidation (stale-while-revalidate) | Custom | Built in |
| Refetch on focus | Custom | Built in |
| Retry | retry | Specified via configuration |
| Loading / error state | Custom (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.