| |

Angular 41 🅰️ RxJS Higher-Order Mapping Operators

The previous two chapters introduced Observables and the transformation and filtering operators. This chapter is about the four operators that cause the most confusion and produce the most bugs in Angular code: switchMap, mergeMap, concatMap, and exhaustMap. They share a signature — a function from a value to an Observable — but they handle concurrency in completely different ways. Choosing the wrong one produces a race condition, a canceled write, a duplicate request, or an ignored user action, and the failure is often invisible until it appears in production. This chapter treats each operator in depth: what it does, what it is for, the mental model that makes it predictable, and the real Angular scenarios where each is the right choice. It also covers the *Map variants that take an index or a result selector, and the *MapTo forms that map every value to the same Observable.

Key point: All four operators take a project function (value) => Observable<Result> and flatten the inner Observables into a single stream. They differ in concurrency: switchMap unsubscribes from the previous inner Observable when a new outer value arrives, so only the latest inner runs to completion. mergeMap subscribes to all inner Observables concurrently, so all of them run to completion. concatMap queues inner Observables and subscribes to them one at a time, in order. exhaustMap ignores new outer values while an inner Observable is running, so only the first inner runs to completion and subsequent outer values are dropped. The choice is determined by whether the operation is a read or a write, whether order matters, and whether duplicates are acceptable.


Why higher-order mapping exists

An Observable that emits Observables is called a higher-order Observable. It is what you get when you map each value from an outer stream to an inner stream without flattening.

searchTerms$.pipe(
  map((term) => this.http.get(`/api/search?q=${term}`)),
);
// Observable<Observable<Result[]>>

The result is a stream of streams. The consumer would have to subscribe to the outer stream, then to each inner stream, which is awkward and prone to leaks. The higher-order mapping operators do the flattening: they subscribe to the inner Observables and merge their values into a single stream.

searchTerms$.pipe(
  switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
);
// Observable<Result[]>

The switchMap flattens the inner Observable into the outer stream, so the consumer sees a single stream of results.

Why map is not enough. map transforms each value, but it does not subscribe. An inner Observable produced by map is not started until it is subscribed to. The higher-order operators subscribe for you, which is what makes them the right tool for any operation that returns an Observable — an HTTP request, a timer, a WebSocket subscription, a route parameter change.

Why the difference is not obvious from the signature. All four operators have the same shape: they take a project function and return an Observable. The difference is in the runtime behavior, which is not visible in the signature. This is why the choice is a design decision, not a type-level one, and why knowing the four is essential.

Why the wrong choice is a silent bug. A mergeMap where switchMap is needed cancels nothing and produces out-of-order results. A switchMap where mergeMap is needed cancels a write that should have completed. The bug does not throw; it produces the wrong result under conditions that may not appear in testing. The diagnosis requires understanding the operator.

Why the four operators cover the cases. The concurrency question has exactly four useful answers: cancel the old, run all in parallel, run one at a time, or ignore new. Each operator is one of those answers. The fifth case — running in parallel with a limit — is covered by mergeMap with a concurrency argument, which is a variation rather than a separate operator.


switchMap — cancel the previous

switchMap projects each outer value to an inner Observable, subscribes to it, and unsubscribes from the previous inner Observable when a new outer value arrives. Only the latest inner Observable runs to completion.

@Component({ /* ... */ })
export class SearchComponent {
  private readonly http = inject(HttpClient);
  readonly search = new FormControl('', { nonNullable: true });

  readonly results$ = this.search.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
  );
}

Every time the user types, valueChanges emits. The debounceTime waits for the typing to pause. The switchMap cancels the previous HTTP request if it is still in flight and starts a new one. Only the most recent request’s result is emitted.

Why cancellation is right for reads. A search result is only useful if it corresponds to the current query. A stale result from an earlier query is wrong, and canceling the request that would produce it saves the bandwidth and the work. The same logic applies to route parameters, filters, and any other input where only the latest value matters.

The route parameter pattern. switchMap is the standard operator for reacting to route parameter changes.

readonly user$ = this.route.paramMap.pipe(
  switchMap((params) => {
    const id = params.get('id')!;
    return this.http.get<User>(`/api/users/${id}`);
  }),
);

When the route parameter changes — the user navigates from one profile to another — switchMap cancels the previous user request and starts a new one. Only the current user’s data is emitted.

Why switchMap inside a chain of writes is wrong. If the outer stream emits a save action and the inner Observable is an HTTP POST, switchMap cancels the first POST when the second arrives. The first save may have partially completed on the server, and the client has no record of it. The correct operator for a sequence of writes is concatMap.

The mental model. switchMap is a switch: it moves the connection from the old inner to the new one, abandoning the old. It is the right shape for a selector — a value that chooses which stream to follow.

Why switchMap is the most commonly misused operator. It is the default in examples, so developers reach for it by habit. But it cancels, and cancellation is only correct when the previous work is no longer wanted. The rule of thumb: if the operation is a read and only the latest result matters, switchMap; if it is a write, use concatMap or exhaustMap.


mergeMap — run in parallel

mergeMap projects each outer value to an inner Observable and subscribes to all of them concurrently. Every inner Observable runs to completion, and their values are merged into the outer stream in the order they arrive.

readonly userIds$ = of(['1', '2', '3']);

readonly users$ = this.userIds$.pipe(
  mergeMap((id) => this.http.get<User>(`/api/users/${id}`)),
);

All three requests are sent at once, and the results are emitted as they arrive. The order is not guaranteed — the request that completes first emits first.

When parallel is right. The operation is independent of the others, and order does not matter. Loading a list of items by ID, fetching several independent resources, or triggering several parallel side effects are the standard cases. The parallelism is what makes the operation fast.

Why the order is not guaranteed. mergeMap does not preserve the order of the outer values. The inner Observables complete at their own pace, and their values are emitted in completion order. If order matters, concatMap is the correct operator.

The concurrency argument. mergeMap accepts a second argument that limits the number of concurrent inner subscriptions.

this.userIds$.pipe(
  mergeMap((id) => this.http.get<User>(`/api/users/${id}`), 3),
);

At most three requests are in flight at once. The rest are queued. This is how a rate limit is enforced without losing parallelism entirely.

Why mergeMap is the general case. It is the operator with the fewest assumptions — it does not cancel, does not queue, and does not ignore. When the other three do not apply, mergeMap is the fallback. The risk is that it runs everything at once, which can overwhelm a server or exhaust a connection pool if the outer stream is fast.

The mental model. mergeMap is a merge: all the inner streams are merged into one, and their values arrive as they come. It is the right shape for independent, order-free work.


concatMap — queue in order

concatMap projects each outer value to an inner Observable and subscribes to them one at a time, in the order the outer values arrived. Each inner Observable completes before the next is subscribed.

readonly saveQueue$ = new Subject<Edit>();

readonly saved$ = this.saveQueue$.pipe(
  concatMap((edit) => this.http.post<Result>('/api/save', edit)),
);

Every edit pushed into saveQueue$ is saved in sequence. The second save does not start until the first completes, and the order is preserved.

Why order preservation matters for writes. A sequence of writes that must be applied in order — creating a resource and then updating it, applying a series of edits — cannot tolerate reordering. concatMap serializes them, so each write sees the state left by the previous one.

The cost of serialization. concatMap runs one inner at a time, so the total time is the sum of the inner durations. For a large queue of slow operations, this is slow. The tradeoff is correctness: the order is preserved at the cost of throughput.

When concatMap is the right choice. Any operation where the order of side effects matters: saves, deletes, state transitions that must be applied sequentially, form submissions that depend on each other. The operator guarantees that the inner Observables run in order and one at a time.

The mental model. concatMap is a queue: it lines up the inner Observables and processes them one by one. It is the right shape for an ordered sequence of operations.

Why concatMap is the safe default for writes. It never cancels and never reorders, so the writes complete in the order they were requested. The cost is throughput, which is usually acceptable for a write path. When the throughput matters and the order does not, mergeMap is the choice, but the order must be genuinely irrelevant.


exhaustMap — ignore while busy

exhaustMap projects the first outer value to an inner Observable and subscribes to it. While the inner Observable is running, new outer values are ignored. When the inner completes, the next outer value can start a new inner.

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

readonly submitted$ = this.submit$.pipe(
  exhaustMap((data) => this.http.post<Result>('/api/submit', data)),
);

The first click sends the request. Any additional clicks while the request is in flight are ignored. When the request completes, the next click can send a new one.

Why ignoring is right for submit buttons. A user who clicks submit twice should not produce two submissions. The second click is a mistake or impatience, and ignoring it prevents the duplicate. The same logic applies to login buttons, payment forms, and any action that must not be repeated.

The difference from switchMap. switchMap cancels the previous and starts the new. exhaustMap ignores the new and keeps the previous. For a submit, ignoring is correct — the first submission is the one the user meant, and the second is a duplicate. Canceling the first and starting the second would be worse, because the first may have partially completed.

The difference from concatMap. concatMap queues the new values and runs them after the current one completes. exhaustMap drops them. For a submit, dropping is correct — a queued duplicate would run after the first completes, which is exactly the double submission the user was trying to avoid.

When exhaustMap is the right choice. Any action where a new request while one is in flight should be ignored: submit buttons, login, refresh, and any operation where concurrency is a mistake. The operator is less common than the other three, but it is the correct one for its case.

The mental model. exhaustMap is a guard: it is busy, and while it is busy, it does not accept new work. It is the right shape for actions that must not be duplicated.


The *MapTo variants and the index

Two variations of each operator appear in real code. The *MapTo forms map every value to the same Observable, and the index argument provides the position of the outer value.

*MapTo. The mapTo forms are shorthand for a project function that ignores its argument.

clicks$.pipe(
  switchMapTo(this.http.get('/api/data')),
);
// equivalent to
clicks$.pipe(
  switchMap(() => this.http.get('/api/data')),
);

The switchMapTo ignores the click and maps every click to the same request. It is useful when the outer value’s content is irrelevant, which is common with button clicks and interval ticks.

The index argument. The project function receives a second argument, the zero-based index of the outer value.

this.ids$.pipe(
  mergeMap((id, index) => this.http.get(`/api/items/${id}?position=${index}`)),
);

The index is useful when the position matters, such as preserving order in a response or attaching a sequence number. It is less commonly used than the value, but it is available in all four operators.

Why the *MapTo forms are less common today. With arrow functions, () => obs is barely longer than obs passed to *MapTo. The *MapTo forms predate the arrow function syntax, and many teams prefer the explicit form for consistency. Both work; the choice is stylistic.

Why the index is worth knowing. The index is available and is sometimes the only way to preserve position information across a mergeMap that does not preserve order. When the index is needed, it is there.


The decision table

The four operators are distinguished by three questions: is the operation a read or a write, does order matter, and are duplicates acceptable? The answers determine the operator.

OperationOrder mattersDuplicatesOperator
SearchNo (latest only)NoswitchMap
Route paramsNo (latest only)NoswitchMap
TypeaheadNo (latest only)NoswitchMap
Load independent itemsNoYesmergeMap
Parallel side effectsNoYesmergeMap
Ordered savesYesYesconcatMap
Sequential editsYesYesconcatMap
Submit buttonNoNoexhaustMap
LoginNoNoexhaustMap
Refresh while busyNoNoexhaustMap

Why the table is the whole chapter. The table is the answer to the recurring question of which operator to use. The rest of the chapter is the reasoning behind each row. A reader who memorizes the table and the reasoning behind it will make the right choice in almost every case.

Why the wrong choice is subtle. A mergeMap used for a search produces out-of-order results, which appear correct until the user types fast enough for the requests to overlap. A switchMap used for a save cancels writes, which appears correct until the user saves twice in quick succession. The bugs are not in the common path; they are in the edges that the tests do not cover.

Why the operators compose with the rest of the pipeline. Each operator is one step in a pipeline, and the pipeline as a whole is what produces the correct behavior. The debounceTime reduces the frequency, the distinctUntilChanged removes duplicates, the switchMap handles the request, and the catchError handles the failure. The operator choice is the concurrency decision within that pipeline.

Why the four operators are the core of RxJS fluency. A developer who knows the four and their decision table can build almost any pipeline. A developer who does not will reach for mergeMap by default, which is the most general and the least correct for most cases. The four are the vocabulary of concurrency, and the choice among them is the design decision.


Complete Example Session

import { Component, DestroyRef, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { Subject, of } from 'rxjs';
import {
  debounceTime, distinctUntilChanged, switchMap, mergeMap,
  concatMap, exhaustMap, catchError, takeUntilDestroyed,
} from 'rxjs/operators';

// ============================================
// PART 1: SWITCHMAP — SEARCH
// ============================================

@Component({
  selector: 'app-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `<input [formControl]="search" />`,
})
export class SearchComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  readonly search = new FormControl('', { nonNullable: true });

  readonly results$ = this.search.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`)),
    catchError(() => of([])),
    takeUntilDestroyed(this.destroyRef),
  );
}

// ============================================
// PART 2: SWITCHMAP — ROUTE PARAMS
// ============================================

@Component({ selector: 'app-user', standalone: true, template: `` })
export class UserComponent {
  private readonly http = inject(HttpClient);
  private readonly route = inject(ActivatedRoute);
  private readonly destroyRef = inject(DestroyRef);

  readonly user$ = this.route.paramMap.pipe(
    switchMap((params) => this.http.get<User>(`/api/users/${params.get('id')}`)),
    takeUntilDestroyed(this.destroyRef),
  );
}

// ============================================
// PART 3: MERGEMAP — PARALLEL LOADS
// ============================================

@Component({ selector: 'app-list', standalone: true, template: `` })
export class ListComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  loadAll(ids: string[]) {
    return of(...ids).pipe(
      mergeMap((id) => this.http.get<Item>(`/api/items/${id}`)),
      takeUntilDestroyed(this.destroyRef),
    );
  }
}

// ============================================
// PART 4: MERGEMAP WITH CONCURRENCY LIMIT
// ============================================

this.ids$.pipe(
  mergeMap((id) => this.http.get(`/api/items/${id}`), 3),
);
// At most 3 requests in flight.

// ============================================
// PART 5: CONCATMAP — ORDERED SAVES
// ============================================

@Component({ selector: 'app-editor', standalone: true, template: `` })
export class EditorComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  private readonly saveQueue$ = new Subject<Edit>();

  readonly saved$ = this.saveQueue$.pipe(
    concatMap((edit) => this.http.post<Result>('/api/save', edit)),
    takeUntilDestroyed(this.destroyRef),
  );

  save(edit: Edit) {
    this.saveQueue$.next(edit);
  }
}

// ============================================
// PART 6: EXHAUSTMAP — SUBMIT BUTTON
// ============================================

@Component({ selector: 'app-form', standalone: true, template: `` })
export class FormComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  private readonly submit$ = new Subject<FormData>();

  readonly submitted$ = this.submit$.pipe(
    exhaustMap((data) => this.http.post<Result>('/api/submit', data)),
    takeUntilDestroyed(this.destroyRef),
  );

  submit(data: FormData) {
    this.submit$.next(data);
  }
}

// ============================================
// PART 7: THE WRONG CHOICES
// ============================================

// Search with mergeMap — out of order
search$.pipe(
  mergeMap((term) => this.http.get(`/search?q=${term}`)),
);
// Results arrive in completion order, not query order.

// Save with switchMap — cancels writes
save$.pipe(
  switchMap((edit) => this.http.post('/save', edit)),
);
// A second save cancels the first.

// Submit with concatMap — queues duplicates
submit$.pipe(
  concatMap((data) => this.http.post('/submit', data)),
);
// A double click submits twice, one after the other.

// ============================================
// PART 8: THE MAPTO VARIANTS
// ============================================

refreshButton$.pipe(
  switchMapTo(this.http.get('/api/data')),
);

// equivalent to

refreshButton$.pipe(
  switchMap(() => this.http.get('/api/data')),
);

// ============================================
// PART 9: THE INDEX ARGUMENT
// ============================================

this.ids$.pipe(
  mergeMap((id, index) => this.http.get(`/api/items/${id}?position=${index}`)),
);
// The index is the position of id in the outer stream.

// ============================================
// PART 10: DECISION TABLE
// ============================================

// Search:     switchMap   (latest only)
// Route:      switchMap   (latest only)
// Load all:   mergeMap    (parallel, order irrelevant)
// Save:       concatMap   (ordered, serialized)
// Submit:     exhaustMap  (ignore while busy)

The ten parts cover each operator in a real Angular scenario, the wrong choices, the variants, and the decision table.


Quick Reference

The Four Operators

OperatorConcurrencyCancelsQueuesIgnores
switchMapLatest only✅❌❌
mergeMapAll at once❌❌❌
concatMapOne at a time❌✅❌
exhaustMapFirst only❌❌✅

Decision Table

OperationOperatorReason
SearchswitchMapLatest result only
Route paramsswitchMapLatest route only
TypeaheadswitchMapLatest query only
Load independentmergeMapParallel, order irrelevant
Ordered savesconcatMapOrder matters
Submit buttonexhaustMapNo duplicates

Signature

FormExample
BasicswitchMap((v) => obs)
With indexmergeMap((v, i) => obs)
MapToswitchMapTo(obs)
With concurrencymergeMap((v) => obs, 3)

Common Mistakes

WrongRightWhy
mergeMap for searchswitchMapOut of order
switchMap for saveconcatMapCancels writes
concatMap for submitexhaustMapQueues duplicates
exhaustMap for searchswitchMapIgnores input
mergeMap for orderedconcatMapReorders

Pipeline Position

OperatorPosition
debounceTimeBefore switchMap
distinctUntilChangedBefore switchMap
filterBefore switchMap
catchErrorAfter the flattening operator
takeUntilDestroyedLast

Best Practices

✅ Do This:

// Use switchMap for reads where only the latest matters
search$.pipe(switchMap((term) => http.get(`/search?q=${term}`))) // ✅

// Use mergeMap for independent parallel work
ids$.pipe(mergeMap((id) => http.get(`/items/${id}`)))           // ✅

// Limit concurrency with mergeMap
ids$.pipe(mergeMap((id) => http.get(`/items/${id}`), 3))        // ✅

// Use concatMap for ordered writes
save$.pipe(concatMap((edit) => http.post('/save', edit)))       // ✅

// Use exhaustMap for submit buttons
submit$.pipe(exhaustMap((data) => http.post('/submit', data)))  // ✅

// Put catchError after the flattening operator
switchMap(...), catchError(() => of([]))                        // ✅

// Put takeUntilDestroyed last
pipe(..., takeUntilDestroyed(this.destroyRef))                  // ✅

❌ Don’t Do This:

// Don't use mergeMap for search
search$.pipe(mergeMap((q) => http.get(`/search?q=${q}`)))       // ⚠️ out of order

// Don't use switchMap for saves
save$.pipe(switchMap((e) => http.post('/save', e)))             // ⚠️ cancels writes

// Don't use concatMap for submit
submit$.pipe(concatMap((d) => http.post('/submit', d)))         // ⚠️ queues duplicates

// Don't use exhaustMap for search
search$.pipe(exhaustMap((q) => http.get(`/search?q=${q}`)))     // ⚠️ ignores input

// Don't forget the concurrency limit on unbounded mergeMap
bigList$.pipe(mergeMap((id) => http.get(`/items/${id}`)))       // ⚠️ floods the server

// Don't put catchError before the flattening operator
catchError(() => of([])), switchMap(...)                        // ⚠️ misses inner errors

Common Pitfalls

PitfallProblemSolution
mergeMap for searchOut-of-order resultsswitchMap
switchMap for writesCanceled savesconcatMap
concatMap for submitQueued duplicatesexhaustMap
exhaustMap for searchIgnored inputswitchMap
Unbounded mergeMapServer floodConcurrency limit
catchError before switchMapMissed inner errorsPlace after
Missing takeUntilDestroyedSubscription leakAdd last
No distinctUntilChangedDuplicate requestsAdd before switchMap

Real-World Examples

1. Search

search$.pipe(debounceTime(300), distinctUntilChanged(), switchMap(search))

2. Route parameter

route.paramMap.pipe(switchMap((p) => http.get(`/users/${p.get('id')}`)))

3. Load multiple items

ids$.pipe(mergeMap((id) => http.get(`/items/${id}`)))

4. Rate-limited parallel load

ids$.pipe(mergeMap((id) => http.get(`/items/${id}`), 3))

5. Ordered saves

saveQueue$.pipe(concatMap((edit) => http.post('/save', edit)))

6. Submit button

submit$.pipe(exhaustMap((data) => http.post('/submit', data)))

7. Refresh button

refresh$.pipe(switchMapTo(http.get('/api/data')))

8. Index in the mapping

ids$.pipe(mergeMap((id, i) => http.get(`/items/${id}?pos=${i}`)))

9. Login

login$.pipe(exhaustMap((creds) => auth.login(creds)))

10. Full pipeline

input$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap((q) => http.get(`/search?q=${q}`)),
  catchError(() => of([])),
  takeUntilDestroyed(this.destroyRef),
)

Visual: The Four Operators

┌──────────────────────────────────────────────────────────┐
│  OUTER:  ──a──────b──────c────►                           │
│                                                          │
│  switchMap                                               │
│    a ──► A                                               │
│    b ──► A canceled, B starts                            │
│    c ──► B canceled, C starts                            │
│    emits: C only                                         │
│    Mental model: a switch, moves to the latest           │
│                                                          │
│  mergeMap                                                │
│    a ──► A                                               │
│    b ──► B                                               │
│    c ──► C                                               │
│    emits: A, B, C in completion order                    │
│    Mental model: a merge, all run at once                │
│                                                          │
│  concatMap                                               │
│    a ──► A                                               │
│    b ──► queued                                          │
│    c ──► queued                                          │
│    emits: A, B, C in order                               │
│    Mental model: a queue, one at a time                  │
│                                                          │
│  exhaustMap                                              │
│    a ──► A                                               │
│    b ──► ignored                                         │
│    c ──► ignored                                         │
│    emits: A only                                         │
│    Mental model: a guard, busy means no new work         │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Search Pipeline

┌──────────────────────────────────────────────────────────┐
│  valueChanges                                            │
│    │  "a", "ab", "abc"                                   │
│    ▼                                                     │
│  debounceTime(300)                                       │
│    │  waits for typing to pause                          │
│    │  emits: "abc"                                       │
│    ▼                                                     │
│  distinctUntilChanged()                                  │
│    │  skips if same as last                              │
│    ▼                                                     │
│  switchMap(term => http.get(...))                        │
│    │  cancels the previous request                       │
│    │  emits: the latest result                           │
│    ▼                                                     │
│  catchError(() => of([]))                                │
│    │  recovers from errors                               │
│    ▼                                                     │
│  takeUntilDestroyed(destroyRef)                          │
│    │  cleans up on destroy                               │
│    ▼                                                     │
│  results                                                 │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Ordered Saves with concatMap

┌──────────────────────────────────────────────────────────┐
│  saveQueue$:  ──edit1──edit2──edit3──►                   │
│                                                          │
│  concatMap(edit => http.post('/save', edit))             │
│                                                          │
│  edit1 ──► POST ──► completes                            │
│                              │                           │
│                              ▼                           │
│  edit2 ──────────────► POST ──► completes                │
│                                        │                 │
│                                        ▼                 │
│  edit3 ──────────────────────► POST ──► completes        │
│                                                          │
│  One at a time, in order.                                │
│  The second save sees the first one's result.            │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Submit with exhaustMap

┌──────────────────────────────────────────────────────────┐
│  submit$:  ──click──click──click──────────click──►       │
│                                                          │
│  exhaustMap(data => http.post('/submit', data))          │
│                                                          │
│  click1 ──► POST ──────────────► completes                │
│               │                                          │
│               │  click2 ignored                          │
│               │  click3 ignored                          │
│               │                                          │
│               └────────────────► click4 ──► POST ──►     │
│                                                          │
│  One submission at a time.                               │
│  Duplicates while busy are dropped.                      │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Decision Flow

┌──────────────────────────────────────────────────────────┐
│  Is the operation a read or a write?                     │
│       │                                                  │
│       ├── Read (only latest matters)                     │
│       │      └── switchMap                               │
│       │                                                  │
│       └── Write                                          │
│            │                                             │
│            ├── Must be ordered?                          │
│            │      └── concatMap                          │
│            │                                             │
│            ├── Must not duplicate?                       │
│            │      └── exhaustMap                         │
│            │                                             │
│            └── Independent, order irrelevant             │
│                   └── mergeMap                           │
│                                                          │
│  When in doubt about a read, switchMap.                  │
│  When in doubt about a write, concatMap or exhaustMap.   │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Concurrency

┌──────────────────────────────────────────────────────────┐
│  switchMap                                               │
│  ┌───┐                                                   │
│  │ A │  ← canceled when B starts                         │
│  └───┘                                                   │
│     ┌───┐                                                │
│     │ B │  ← canceled when C starts                      │
│     └───┘                                                │
│        ┌───┐                                             │
│        │ C │  ← completes                                │
│        └───┘                                             │
│                                                          │
│  mergeMap                                                │
│  ┌───┐                                                   │
│  │ A │                                                   │
│  └───┘                                                   │
│     ┌───┐                                                │
│     │ B │                                                │
│     └───┘                                                │
│        ┌───┐                                             │
│        │ C │                                             │
│        └───┘                                             │
│  All run concurrently.                                   │
│                                                          │
│  concatMap                                               │
│  ┌───┐                                                   │
│  │ A │                                                   │
│  └───┘                                                   │
│       ┌───┐                                              │
│       │ B │                                              │
│       └───┘                                              │
│          ┌───┐                                           │
│          │ C │                                           │
│          └───┘                                           │
│  Sequential, in order.                                   │
│                                                          │
│  exhaustMap                                              │
│  ┌───┐                                                   │
│  │ A │                                                   │
│  └───┘                                                   │
│     B ignored, C ignored                                 │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

OperatorConcurrencyMental ModelUse
switchMapLatest onlySwitchSearch, route params
mergeMapAll at onceMergeIndependent parallel work
concatMapOne at a timeQueueOrdered writes
exhaustMapFirst onlyGuardSubmit, login

Key takeaways:

  • All four operators flatten inner Observables — they take a project function and subscribe to the inner streams, merging their values into the outer stream
  • They differ in concurrency, and the difference is a design decision — cancel, parallel, queue, or ignore
  • switchMap cancels the previous inner — correct for reads where only the latest result matters, such as search and route parameters
  • mergeMap runs all inner Observables in parallel — correct for independent work where order does not matter, and it accepts a concurrency limit
  • concatMap queues inner Observables and runs them in order — correct for writes and any operation where order matters
  • exhaustMap ignores new outer values while an inner is running — correct for submit buttons and any action where duplicates must be prevented
  • The wrong choice is a silent bug — mergeMap for search produces out-of-order results, switchMap for saves cancels writes, concatMap for submit queues duplicates
  • The *MapTo variants map every value to the same Observable — useful for button clicks and interval ticks where the value is irrelevant
  • The index argument provides the position of the outer value — useful when position must be preserved across a mergeMap
  • The decision flow is the whole chapter — read or write, order or not, duplicates acceptable or not, and the answers determine the operator

Remember: The four higher-order mapping operators are the vocabulary of concurrency in RxJS. They share a signature and differ in behavior, and the choice among them is the design decision that determines whether the pipeline is correct. switchMap for reads, mergeMap for independent parallel work, concatMap for ordered writes, and exhaustMap for actions that must not duplicate. The wrong choice produces a bug that does not throw, which is why understanding the four is essential for anyone writing RxJS pipelines in Angular.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!