Skip to content
Angular ng http 5 min read

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 switchMap whenever a newer request should supersede an older one (search boxes, route changes). If you must let every request finish, switch to concatMap or mergeMap instead — switchMap would 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, forkJoin emits nothing and forwards that error — partial results are lost. Wrap individual requests with catchError returning 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

OperatorConcurrencyCancels previous?Preserves order?Typical use
switchMapOne at a timeYesn/aType-ahead, route params, latest-wins
concatMapOne at a timeNoYesSequential writes, ordered side effects
mergeMapUnboundedNoNoIndependent calls where order is irrelevant
exhaustMapOne at a timeNo (ignores new)n/aPrevent double submits (e.g. login button)
forkJoinAll in parallelNon/aLoad multiple resources, then render once

Best Practices

  • Reach for a flattening operator (switchMap/concatMap/mergeMap) instead of nesting subscribe calls — it keeps cancellation and error handling intact.
  • Default to switchMap for read-driven UIs and concatMap/exhaustMap for writes, so you never accidentally cancel a request that mutates server state.
  • Prefer the object form of forkJoin for named, type-safe results, and guard each request with catchError when a partial render is acceptable.
  • Convert signals to observables with toObservable and back with toSignal so your RxJS pipelines integrate cleanly with the template and the async pipe is rarely needed.
  • Always type your HttpClient calls (get<Order[]>(...)) so the operator chain stays fully typed end to end.
  • Keep side effects (like signal.set) in the final subscribe or a tap, not buried inside mapping operators.
Last updated June 14, 2026
Was this helpful?