Skip to content

HttpClient Patterns#

HttpClient returns cold, single-emission Observables that complete after the response. That one sentence powers every pattern on this page, and most HTTP interview questions.

Pattern 1: Loading / Error / Success State#

Model the view state explicitly and drive it from one pipeline:

import { Component, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { toSignal } from "@angular/core/rxjs-interop";
import { catchError, map, of, startWith } from "rxjs";

type State<T> =
  | { status: "loading" }
  | { status: "error" }
  | { status: "loaded"; data: T };

@Component({
  selector: "app-products",
  template: `
    @switch (state().status) {
      @case ("loading") {
        <p>Loading...</p>
      }
      @case ("error") {
        <p>Could not load products.</p>
      }
      @case ("loaded") {
        <p>{{ $any(state()).data.length }} products</p>
      }
    }
  `,
})
export class ProductsComponent {
  private readonly http = inject(HttpClient);

  protected readonly state = toSignal(
    this.http.get<string[]>("/api/products").pipe(
      map((data): State<string[]> => ({ status: "loaded", data })),
      catchError(() => of<State<string[]>>({ status: "error" })),
      startWith<State<string[]>>({ status: "loading" }),
    ),
    { requireSync: true },
  );
}

One stream, no boolean flag drift, exhaustive template handling. The pieces are explained on startWith and catchError.

Pattern 2: Sequential (Dependent) Requests#

When request B needs data from request A, chain with switchMap:

readonly orders$ = this.route.paramMap.pipe(
  map((params) => params.get("userId")!),
  switchMap((userId) => this.http.get<User>(`/api/users/${userId}`)),
  switchMap((user) => this.http.get<Order[]>(`/api/orders`, { params: { customerId: user.customerId } }))
);

Each step cancels cleanly if the route changes mid-chain. Nested subscribes are the anti-pattern this replaces.

Pattern 3: Parallel Requests#

Independent requests should not wait for each other. forkJoin runs them concurrently and emits once with all final results:

readonly dashboard$ = forkJoin({
  profile: this.http.get<Profile>("/api/profile").pipe(catchError(() => of(null))),
  stats: this.http.get<Stats>("/api/stats").pipe(catchError(() => of(null))),
  news: this.http.get<News[]>("/api/news").pipe(catchError(() => of([]))),
});

Per-request catchError keeps one failure from discarding the other responses. For "results as they arrive" instead of "all at once", use mergeMap over the request list.

Pattern 4: Cancellation#

Unsubscribing from an HttpClient stream aborts the underlying request. You rarely call unsubscribe yourself; operators do it:

  • switchMap cancels the stale request when a new trigger arrives (typeahead, route changes, refresh).
  • takeUntilDestroyed() aborts in-flight requests when the user navigates away.
  • exhaustMap prevents duplicate requests instead of cancelling (submit buttons).

The triple-click-save scenario, which operator fires how many requests, is covered in the mapping comparison.

Pattern 5: Resilience (Timeout + Retry + Fallback)#

The hardened request pipeline, in canonical order:

readonly config$ = this.http.get<Config>("/api/config").pipe(
  timeout(5000), // each attempt gets 5s
  retry({
    count: 2,
    delay: (error, retryCount) =>
      isTransient(error) ? timer(500 * retryCount) : throwError(() => error),
  }),
  catchError(() => of(DEFAULT_CONFIG)) // final fallback
);

Order matters and is a classic question: timeout inside so each retry attempt is bounded, retry before catchError so it sees raw errors, catchError last as the safety net.

Pattern 6: Share One Request Among Many Consumers#

Cold means each subscriber re-runs the request. Cache deliberately:

readonly currentUser$ = this.http.get<User>("/api/me").pipe(
  shareReplay({ bufferSize: 1, refCount: true })
);

Staleness, refresh triggers, and the completion caveat are on shareReplay.

Common Mistakes#

Boolean flag soup. loading, error, data as three independent properties drift out of sync on edge cases. A single discriminated-union state (Pattern 1) cannot represent impossible combinations.

forkJoin with a source that never completes. It waits for completion; one stray Subject input means it never emits. HTTP calls are safe inputs; live streams are not.

Retrying everything. A 400/404 will fail identically on every attempt. Gate the retry on error type, as in Pattern 5, and never retry non-idempotent writes blindly.

Interview Q&A#

How do you fetch data that depends on a previous response?

Chain switchMaps: route param → user → user's orders. Each stage maps a value to the next request, cancellation flows through the whole chain, and one catchError at the appropriate level handles failures. The wrong answer is nested subscribes.

Three API calls must all finish before rendering. What do you use and what is the failure mode?

forkJoin (dictionary form for named results). Failure mode one: any uncaught error kills the join, so catch per request. Failure mode two: a non-completing input means it never emits. combineLatest is the alternative when sources keep emitting.

Does Angular cancel HTTP requests when a component is destroyed?

Not by itself. Cancellation happens when the subscription is torn down: via switchMap replacing it, takeUntilDestroyed/async pipe on destroy, or manual unsubscribe. Unsubscribing an in-flight HttpClient request aborts it at the network layer.