HTTP with RxJS Operators
Every HttpClient method returns an Observable, which means RxJS operators are the natural language for orchestrating real-world data flows. Whether you need to chain one request onto the result of another, fire several requests at once, or react to a stream of user input without overlapping calls, the right operator turns awkward nested subscriptions into a single readable pipeline. This page covers the three operators you will reach for most: switchMap, forkJoin, and concatMap.
Why operators beat nested subscribes
It is tempting to subscribe inside a subscribe when one request depends on another. This nests callbacks, leaks subscriptions, and makes error handling and cancellation nearly impossible. Higher-order mapping operators flatten the result of an inner observable into the outer stream, so the whole flow stays a single Observable<T> you subscribe to (or pipe through async) exactly once.
// Anti-pattern: nested subscribe
this.http.get<User>('/api/me').subscribe((user) => {
this.http.get<Order[]>(`/api/users/${user.id}/orders`).subscribe((orders) => {
this.orders.set(orders); // hard to cancel, hard to handle errors
});
});
The sections below show the idiomatic replacements.
Dependent requests with switchMap
switchMap maps each emitted value to a new inner observable and cancels any previous inner request that has not completed. That cancellation behaviour makes it perfect for type-ahead search and route-param-driven loads, where only the latest request matters.
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
interface User { id: number; name: string; }
interface Order { id: number; total: number; }
@Component({
selector: 'app-user-orders',
standalone: true,
template: `
@for (order of orders(); track order.id) {
<p>Order #{{ order.id }} — {{ order.total | currency }}</p>
}
`,
})
export class UserOrdersComponent {
private http = inject(HttpClient);
readonly userId = signal(1);
// Each new userId cancels the in-flight orders request and starts a fresh one.
readonly orders = toSignal(
toObservable(this.userId).pipe(
switchMap((id) => this.http.get<Order[]>(`/api/users/${id}/orders`)),
),
{ initialValue: [] as Order[] },
);
}
When userId changes from 1 to 2 before the first response lands, Angular tears down the first HTTP subscription, so the stale response is never applied.
Output:
Order #4821 — $129.00
Order #4822 — $58.50
Use
switchMapwhenever a newer request should supersede an older one (search boxes, route changes). If you must let every request finish, switch toconcatMapormergeMapinstead —switchMapwould silently drop the earlier ones.
Parallel requests with forkJoin
forkJoin runs a group of observables concurrently and emits a single combined value once all of them complete. Because each HttpClient request completes after one emission, forkJoin is the standard tool for “load everything this screen needs, then render.”
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin } from 'rxjs';
interface Profile { id: number; name: string; }
interface Settings { theme: string; }
interface Notification { id: number; text: string; }
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
@if (loaded()) {
<h1>Welcome, {{ profile()?.name }}</h1>
<p>Theme: {{ settings()?.theme }}</p>
<p>{{ notifications().length }} notifications</p>
} @else {
<p>Loading dashboard…</p>
}
`,
})
export class DashboardComponent {
private http = inject(HttpClient);
readonly profile = signal<Profile | null>(null);
readonly settings = signal<Settings | null>(null);
readonly notifications = signal<Notification[]>([]);
readonly loaded = signal(false);
load(): void {
forkJoin({
profile: this.http.get<Profile>('/api/profile'),
settings: this.http.get<Settings>('/api/settings'),
notifications: this.http.get<Notification[]>('/api/notifications'),
}).subscribe(({ profile, settings, notifications }) => {
this.profile.set(profile);
this.settings.set(settings);
this.notifications.set(notifications);
this.loaded.set(true);
});
}
}
The object form is preferred over the array form because you get named, type-safe properties instead of positional destructuring.
Output:
Welcome, Ada Lovelace
Theme: dark
3 notifications
Gotcha: if any inner observable errors,
forkJoinemits nothing and forwards that error — partial results are lost. Wrap individual requests withcatchErrorreturning a fallback (e.g.of(null)) when you want the screen to render even if one call fails.
Sequential, order-preserving requests with concatMap
concatMap maps each value to an inner observable but, unlike switchMap, it queues them and runs them strictly one after another, preserving order and never cancelling. Use it when each request has a side effect that must not be dropped, such as saving a batch of edits in sequence.
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { from, concatMap, toArray } from 'rxjs';
interface Edit { id: number; field: string; value: string; }
export function saveEditsInOrder(edits: Edit[]) {
const http = inject(HttpClient);
return from(edits).pipe(
concatMap((edit) =>
http.patch(`/api/records/${edit.id}`, { [edit.field]: edit.value }),
),
toArray(), // collect every response, emitted once all PATCHes finish
);
}
Each PATCH waits for the previous one to complete, which is exactly what you want when later edits may depend on earlier ones being persisted.
Choosing the right operator
| Operator | Concurrency | Cancels previous? | Preserves order? | Typical use |
|---|---|---|---|---|
switchMap | One at a time | Yes | n/a | Type-ahead, route params, latest-wins |
concatMap | One at a time | No | Yes | Sequential writes, ordered side effects |
mergeMap | Unbounded | No | No | Independent calls where order is irrelevant |
exhaustMap | One at a time | No (ignores new) | n/a | Prevent double submits (e.g. login button) |
forkJoin | All in parallel | No | n/a | Load multiple resources, then render once |
Best Practices
- Reach for a flattening operator (
switchMap/concatMap/mergeMap) instead of nestingsubscribecalls — it keeps cancellation and error handling intact. - Default to
switchMapfor read-driven UIs andconcatMap/exhaustMapfor writes, so you never accidentally cancel a request that mutates server state. - Prefer the object form of
forkJoinfor named, type-safe results, and guard each request withcatchErrorwhen a partial render is acceptable. - Convert signals to observables with
toObservableand back withtoSignalso your RxJS pipelines integrate cleanly with the template and theasyncpipe is rarely needed. - Always type your
HttpClientcalls (get<Order[]>(...)) so the operator chain stays fully typed end to end. - Keep side effects (like
signal.set) in the finalsubscribeor atap, not buried inside mapping operators.