RxJS operators transform, filter, and combine Observable streams. The most consequential choice in Angular development is which higher-order mapping operator to use when an Observable value triggers another Observable — the four options (switchMap, mergeMap, concatMap, exhaustMap) determine how concurrent inner Observables are handled. Getting this wrong causes duplicate requests, race conditions, or missed events. For HTTP requests triggered by user input, switchMap is almost always correct — it cancels the previous in-flight request when a new input arrives.
Essential Operators
import { switchMap, mergeMap, concatMap, exhaustMap,
map, filter, debounceTime, distinctUntilChanged,
catchError, tap, finalize, shareReplay,
combineLatest, forkJoin, withLatestFrom } from 'rxjs/operators';
// ── Higher-order mapping operators — the critical choice ──────────────────
// switchMap: CANCEL previous inner Observable when source emits
// ✅ CORRECT for: search queries, route navigation, any "latest wins" scenario
searchQuery$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.api.search(query)) // previous request cancelled on new query
).subscribe(results => this.results.set(results));
// mergeMap: RUN ALL inner Observables concurrently (no cancellation)
// ✅ CORRECT for: parallel requests, fire-and-forget operations
postIds$.pipe(
mergeMap(id => this.api.getPostById(id)) // all requests run in parallel
).subscribe(post => this.posts.update(p => [...p, post]));
// concatMap: QUEUE inner Observables, process one at a time in order
// ✅ CORRECT for: sequential operations that must not be reordered (audit log entries)
actions$.pipe(
concatMap(action => this.api.processAction(action)) // waits for each to complete
).subscribe();
// exhaustMap: IGNORE new values while inner Observable is running
// ✅ CORRECT for: submit button (ignore clicks while request is in flight)
submitClicks$.pipe(
exhaustMap(() => this.api.submitForm(this.form.value)) // ignores extra clicks
).subscribe(result => this.handleResult(result));
// ── Filtering operators ────────────────────────────────────────────────────
source$.pipe(filter(v => v > 0)) // only positive values
source$.pipe(take(5)) // first 5 values then complete
source$.pipe(debounceTime(300)) // wait 300ms after last emission
source$.pipe(distinctUntilChanged()) // only emit when value changes
source$.pipe(throttleTime(1000)) // max one emission per second
// ── Combination operators ─────────────────────────────────────────────────
// combineLatest — emit when ANY source changes (both must have emitted once)
combineLatest([user$, posts$]).subscribe(([user, posts]) => {
// Fires whenever user or posts changes
});
// forkJoin — wait for ALL to complete, emit the last values
forkJoin([this.api.getUser(), this.api.getPosts()]).subscribe(([user, posts]) => {
// Both HTTP calls complete before this runs
this.pageData.set({ user, posts });
});
// withLatestFrom — combine with latest value of another stream
submit$.pipe(
withLatestFrom(this.form.valueChanges),
switchMap(([_, formData]) => this.api.save(formData))
).subscribe();
// ── Error handling ────────────────────────────────────────────────────────
source$.pipe(
catchError(err => {
this.error.set(err.message);
return EMPTY; // recover from error, complete the stream
}),
finalize(() => this.loading.set(false)), // always runs (success or error)
retry(2), // retry up to 2 times before propagating error
)
switchMap: cancel the previous, start the new (last wins). mergeMap: keep all running in parallel (all win). concatMap: wait for the previous to finish, then start the new (queue, first in first out). exhaustMap: discard the new, let the current finish (current wins). For the majority of search/filter/load-on-change patterns in Angular, switchMap is correct because you only care about the result for the current input.debounceTime and distinctUntilChanged together for any user-input-driven Observable. debounceTime(300) prevents excessive emissions during fast typing; distinctUntilChanged() prevents re-processing when the value hasn’t changed (user types “a”, deletes, types “a” again — same value, no new emission). These two operators together are the standard preamble for any search or filter Observable pipeline.mergeMap can cause race conditions with HTTP requests. If the user clicks a save button twice and you use mergeMap, both requests run in parallel. The first request might complete after the second, making the server state reflect the first (older) request’s data. Use exhaustMap for submit/save operations to ignore additional clicks while a request is in flight. Only use mergeMap for genuinely independent parallel operations where order does not matter.Higher-Order Mapping Decision Guide
| Operator | When inner is still running… | Use for |
|---|---|---|
| switchMap | Cancel previous, start new | Search, typeahead, route load |
| mergeMap | Run in parallel | Independent parallel requests |
| concatMap | Queue, wait for current | Sequential ordered operations |
| exhaustMap | Ignore new emissions | Submit, save, prevent double-click |
Common Mistakes
Mistake 1 — Using mergeMap for search queries (race conditions with out-of-order responses)
❌ Wrong — fast typing sends requests A, B, C; response C arrives before B; UI shows B’s stale results.
✅ Correct — use switchMap; cancels A and B when C is emitted; only C’s response matters.
Mistake 2 — Using switchMap for submit operations (cancels the save on next click)
❌ Wrong — user double-clicks Save; switchMap cancels the first request mid-flight; data not saved.
✅ Correct — use exhaustMap; ignores the second click until the first save completes.