Ch. 4 · Angular

switchMap vs mergeMap vs concatMap vs exhaustMap

The four RxJS flattening operators differ in one thing: what happens when a new value arrives while the previous inner observable is still running.

~6 min readintermediate

Almost every Angular app has code like route.paramMap.pipe(switchMap(...)), and almost every Angular interview eventually asks why it’s switchMap and not mergeMap. The four flattening operators look interchangeable until two requests overlap. Then one cancels, one runs both, one waits its turn and one refuses. This note builds the mental model, runs the same timeline through all four, and maps each operator to the use cases you’ll be asked about.

Higher-order observables

When you map a value to an HTTP call, you get an observable of observables:

const query$ = new Subject<string>();
const search = (q: string) => this.http.get<User[]>('/api/users', { params: { q } });

// Observable<Observable<User[]>>: nobody subscribes to the inner requests
const nested$ = query$.pipe(map(search));

// Observable<User[]>: switchMap subscribes to each inner and forwards its values
const users$ = query$.pipe(switchMap(search));
TypeScript

An observable that emits observables is a higher-order observable. A flattening operator subscribes to each inner observable and merges its values into one output stream. switchMap, mergeMap, concatMap and exhaustMap are map combined with switchAll, mergeAll, concatAll and exhaustAll. They disagree about one question only: a new outer value arrives while the previous inner observable is still running. What now?

  • switchMap unsubscribes from the running inner (cancels it) and switches to the new one.
  • mergeMap subscribes to the new one as well and runs them concurrently.
  • concatMap queues the new value and starts it after the current inner completes.
  • exhaustMap ignores the new value while an inner is still running.

Interview tip

Lead with the overlap question: “They all flatten; the difference is concurrency. Switch cancels, merge parallelizes, concat queues, exhaust ignores.” Then give one real use case for each. That’s usually the whole answer the interviewer is looking for.

One timeline, four operators

Here’s a setup where the inner observables overlap on purpose. The outer stream emits 1, 2 and 3 ten milliseconds apart, and each inner takes 30ms:

import { interval, timer, concat, take, map, switchMap } from 'rxjs';

// Outer: 1 at 10ms, 2 at 20ms, 3 at 30ms
const clicks$ = interval(10).pipe(take(3), map((i) => i + 1));

// Inner: `${n}a` 5ms after it starts, `${n}b` 25ms after that, then completes
const request = (n: number) =>
  concat(
    timer(5).pipe(map(() => `${n}a`)),
    timer(25).pipe(map(() => `${n}b`)),
  );

clicks$.pipe(switchMap(request)).subscribe(console.log);
TypeScript

Swapping the operator gives these results (RxJS 7.8, run in virtual time; real timers produce the same order):

Operator Output (value@ms) What happened
switchMap 1a@15 2a@25 3a@35 3b@60 2 canceled inner 1 before 1b; 3 canceled inner 2
mergeMap 1a@15 2a@25 3a@35 1b@40 2b@50 3b@60 All three ran at once, so results interleave
concatMap 1a@15 1b@40 2a@45 2b@70 3a@75 3b@100 Each inner waited for the previous one to complete
exhaustMap 1a@15 1b@40 2 and 3 arrived while inner 1 was busy and were dropped

Two details worth noticing. concatMap finishes at 100ms instead of 60ms, because queuing trades latency for order. And every operator waits for its active inner observables to complete before it completes itself, which is why switchMap still delivers 3b after the source is done.

Real use cases for each

switchMap: only the latest matters

The textbook case is typeahead search:

@Component({
  selector: 'app-user-search',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="query" />
    @for (user of results(); track user.id) { <p>{{ user.name }}</p> }
  `,
})
export class UserSearch {
  private readonly http = inject(HttpClient);
  readonly query = new FormControl('', { nonNullable: true });

  readonly results = toSignal(
    this.query.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap((q) =>
        this.http.get<User[]>('/api/users', { params: { q } }).pipe(catchError(() => of([]))),
      ),
    ),
    { initialValue: [] },
  );
}
TypeScript

When the user types “an” and then “ang”, the results for “an” are stale. switchMap unsubscribes from that request, and HttpClient aborts the underlying HTTP call on unsubscribe, so a slow “an” response can never overwrite the “ang” results. With mergeMap you’d get a race: whichever response lands last wins. The same logic applies to loading details for the current route parameter.

Gotcha

Don’t use switchMap for writes. Canceling only unsubscribes on the client; the server may already have received the request and processed it. For saves, deletes and payments you want concatMap or exhaustMap, which never abandon a request halfway.

mergeMap: independent work in parallel

// Load every selected product, with at most 4 requests in flight at once
from(productIds)
  .pipe(mergeMap((id) => this.http.get<Product>(`/api/products/${id}`), 4))
  .subscribe((product) => this.cache.set(product.id, product));
TypeScript

Use mergeMap when each inner is independent and order doesn’t matter: loading several resources, handling incoming WebSocket messages, marking notifications as read. Results arrive in completion order, not source order. The optional second argument caps concurrency, and without it there’s no limit, which can flood a server. In fact concatMap is mergeMap with a concurrency of 1. If you only need all the results together at the end, forkJoin is often clearer.

concatMap: every value, in order

constructor() {
  this.form.valueChanges
    .pipe(
      debounceTime(500),
      concatMap((draft) => this.api.saveDraft(draft)),
      takeUntilDestroyed(),
    )
    .subscribe();
}
TypeScript

This autosave sends every change, strictly one at a time. With mergeMap, save #2 could finish before save #1 and the server would end up holding the older draft. With switchMap, a save could be canceled mid-flight. concatMap guarantees order at the cost of latency, and its queue grows without bound if the source emits faster than the inner observables complete.

exhaustMap: ignore while busy

readonly submit$ = new Subject<void>();

constructor() {
  this.submit$
    .pipe(
      exhaustMap(() => this.auth.login(this.form.getRawValue())),
      takeUntilDestroyed(),
    )
    .subscribe(() => this.router.navigate(['/dashboard']));
}
TypeScript

Wire the button with (click)="submit$.next()". A double-click now sends one login request, because the second click arrives while the first request is still running and is simply dropped. The same fits refresh buttons and “load more” triggers. Keep disabling the button for good UX, and treat exhaustMap as the safety net that makes duplicates impossible.

Choosing the operator

Question Pick Overlap behavior Classic use case
Only the latest result matters? switchMap Cancels the previous inner Typeahead, route param to details
Every value, order irrelevant? mergeMap Runs inners concurrently Parallel loads, independent writes
Every value, strictly in order? concatMap Queues until the previous completes Autosave, ordered uploads
Ignore repeats while busy? exhaustMap Drops values until the inner completes Login, submit, refresh button

Error handling: catch inside

An error in an inner observable propagates to the outer stream. If it reaches the end of the pipe, the whole subscription dies: after one failed search, the typeahead stops responding. Where you put catchError decides what it replaces:

const load = (id: number) =>
  id === 2 ? throwError(() => new Error(`load ${id} failed`)) : of(`item ${id}`);

// Outside: catchError replaces the whole stream, so it completes after the error
of(1, 2, 3)
  .pipe(concatMap(load), catchError(() => EMPTY))
  .subscribe(console.log); // item 1

// Inside: only the failed inner is replaced, and the outer keeps going
of(1, 2, 3)
  .pipe(concatMap((id) => load(id).pipe(catchError(() => of(null)))))
  .subscribe(console.log); // item 1, null, item 3
TypeScript

That’s why the typeahead above has catchError(() => of([])) on the HTTP call itself. The same rule applies to retry: put it on the inner observable to retry just the request, not the whole source.

Note

These examples use toSignal() and takeUntilDestroyed() from @angular/core/rxjs-interop. Both need an injection context, which field initializers and constructors provide. If you’re wondering how the signal gets to the screen, see change detection and signals.

The interview answer

“All four map each value to an inner observable and subscribe to it. They differ when a new value arrives while an inner is still active. switchMap cancels the old one and switches, so it’s right for typeahead or loading details for the current route. mergeMap runs them concurrently, optionally with a concurrency limit, for independent parallel work. concatMap queues them and runs one at a time in order, which suits sequential saves. exhaustMap ignores new values until the current inner completes, which prevents double submits on a login button.

I avoid switchMap for writes, because cancellation doesn’t undo a request the server already received. And I put catchError inside the inner observable, so one failed request doesn’t kill the outer stream.”

More in Angular

read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc