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
mergeMapwith 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
switchMapis 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, useconcatMaporexhaustMap.
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
concatMapis 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,mergeMapis 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.
| Operation | Order matters | Duplicates | Operator |
|---|---|---|---|
| Search | No (latest only) | No | switchMap |
| Route params | No (latest only) | No | switchMap |
| Typeahead | No (latest only) | No | switchMap |
| Load independent items | No | Yes | mergeMap |
| Parallel side effects | No | Yes | mergeMap |
| Ordered saves | Yes | Yes | concatMap |
| Sequential edits | Yes | Yes | concatMap |
| Submit button | No | No | exhaustMap |
| Login | No | No | exhaustMap |
| Refresh while busy | No | No | exhaustMap |
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
mergeMapby 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
| Operator | Concurrency | Cancels | Queues | Ignores |
|---|---|---|---|---|
switchMap | Latest only | ✅ | ❌ | ❌ |
mergeMap | All at once | ❌ | ❌ | ❌ |
concatMap | One at a time | ❌ | ✅ | ❌ |
exhaustMap | First only | ❌ | ❌ | ✅ |
Decision Table
| Operation | Operator | Reason |
|---|---|---|
| Search | switchMap | Latest result only |
| Route params | switchMap | Latest route only |
| Typeahead | switchMap | Latest query only |
| Load independent | mergeMap | Parallel, order irrelevant |
| Ordered saves | concatMap | Order matters |
| Submit button | exhaustMap | No duplicates |
Signature
| Form | Example |
|---|---|
| Basic | switchMap((v) => obs) |
| With index | mergeMap((v, i) => obs) |
| MapTo | switchMapTo(obs) |
| With concurrency | mergeMap((v) => obs, 3) |
Common Mistakes
| Wrong | Right | Why |
|---|---|---|
mergeMap for search | switchMap | Out of order |
switchMap for save | concatMap | Cancels writes |
concatMap for submit | exhaustMap | Queues duplicates |
exhaustMap for search | switchMap | Ignores input |
mergeMap for ordered | concatMap | Reorders |
Pipeline Position
| Operator | Position |
|---|---|
debounceTime | Before switchMap |
distinctUntilChanged | Before switchMap |
filter | Before switchMap |
catchError | After the flattening operator |
takeUntilDestroyed | Last |
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
| Pitfall | Problem | Solution |
|---|---|---|
mergeMap for search | Out-of-order results | switchMap |
switchMap for writes | Canceled saves | concatMap |
concatMap for submit | Queued duplicates | exhaustMap |
exhaustMap for search | Ignored input | switchMap |
Unbounded mergeMap | Server flood | Concurrency limit |
catchError before switchMap | Missed inner errors | Place after |
Missing takeUntilDestroyed | Subscription leak | Add last |
No distinctUntilChanged | Duplicate requests | Add 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
| Operator | Concurrency | Mental Model | Use |
|---|---|---|---|
switchMap | Latest only | Switch | Search, route params |
mergeMap | All at once | Merge | Independent parallel work |
concatMap | One at a time | Queue | Ordered writes |
exhaustMap | First only | Guard | Submit, 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
switchMapcancels the previous inner — correct for reads where only the latest result matters, such as search and route parametersmergeMapruns all inner Observables in parallel — correct for independent work where order does not matter, and it accepts a concurrency limitconcatMapqueues inner Observables and runs them in order — correct for writes and any operation where order mattersexhaustMapignores 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 —
mergeMapfor search produces out-of-order results,switchMapfor saves cancels writes,concatMapfor submit queues duplicates - The
*MapTovariants 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!