Operators Reference
The flattening operators
Section titled “The flattening operators”This is the decision that causes the most production bugs in Angular applications. All four take an inner observable; the difference is what they do when a new value arrives while a previous inner observable is still running.
| Operator | On a new value | Use when |
|---|---|---|
switchMap | Cancels the previous | Only the latest result matters |
mergeMap | Runs in parallel | Order is irrelevant, all must complete |
concatMap | Queues it | Order matters, all must complete |
exhaustMap | Ignores it until the current finishes | Prevent duplicate submissions |
Visual model
Section titled “Visual model”Source emits A, then B before A’s request finishes:
switchMap A──✗(cancelled) B─────►result(B)mergeMap A─────►result(A) B───►result(B) // order not guaranteedconcatMap A─────►result(A) then B─────►result(B)exhaustMap A─────►result(A) B ignoredswitchMap — search, filters, route params
Section titled “switchMap — search, filters, route params”The default choice for anything driven by user input where only the latest value is relevant.
readonly results$ = this.searchControl.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), switchMap((term) => this.api.search(term)),);Without switchMap you get a classic race condition: the user types ang, then angular; if the ang response is slower, it arrives last and overwrites the correct results.
Also ideal for route parameters:
readonly product$ = this.route.paramMap.pipe( map((params) => params.get('id')!), switchMap((id) => this.api.getProduct(id)),);concatMap — ordered writes
Section titled “concatMap — ordered writes”Use it when every emission must be processed, in order.
readonly saveOrder$ = this.reorder$.pipe( concatMap((positions) => this.api.savePositions(positions)),);mergeMap — independent parallel work
Section titled “mergeMap — independent parallel work”// Max 3 concurrent uploadsreadonly uploads$ = from(files).pipe( mergeMap((file) => this.api.upload(file), 3),);The second argument is the concurrency limit — use it. Unlimited mergeMap over a large source will open hundreds of connections.
exhaustMap — the anti double-click operator
Section titled “exhaustMap — the anti double-click operator”readonly login$ = this.submit$.pipe( exhaustMap(() => this.auth.login(this.form.getRawValue())),);This removes the need for a isSubmitting boolean flag in most forms.
Combination operators
Section titled “Combination operators”combineLatest — “when any of these change”
Section titled “combineLatest — “when any of these change””readonly filteredUsers$ = combineLatest([ this.users$, this.searchTerm$.pipe(startWith('')), this.onlyActive$.pipe(startWith(false)),]).pipe( map(([users, term, onlyActive]) => filterUsers(users, term, onlyActive)),);In modern Angular, when all sources are synchronous state, computed() is simpler and cheaper:
readonly filteredUsers = computed(() => filterUsers(this.users(), this.searchTerm(), this.onlyActive()),);forkJoin — “wait for all to finish”
Section titled “forkJoin — “wait for all to finish””readonly pageData$ = forkJoin({ user: this.api.getUser(id), orders: this.api.getOrders(id), preferences: this.api.getPreferences(id),});forkJoin({ user: this.api.getUser(id), orders: this.api.getOrders(id).pipe(catchError(() => of([]))),});withLatestFrom — “when A changes, read the current B”
Section titled “withLatestFrom — “when A changes, read the current B””readonly save$ = this.saveClick$.pipe( withLatestFrom(this.filters$), switchMap(([, filters]) => this.api.saveView(filters)),);The difference with combineLatest is crucial: only the first source triggers the emission.
merge — several triggers, one handler
Section titled “merge — several triggers, one handler”readonly refresh$ = merge( this.manualRefresh$, this.router.events.pipe(filter((e) => e instanceof NavigationEnd)), interval(60_000),);Filtering and rate-limiting
Section titled “Filtering and rate-limiting”| Operator | Behaviour |
|---|---|
debounceTime(300) | Emits after 300 ms of silence — user typing |
throttleTime(300) | Emits at most once per 300 ms — scroll, resize |
auditTime(300) | Emits the last value of each window — smoother than throttle |
distinctUntilChanged() | Skips consecutive duplicates |
filter(Boolean) | Removes null/undefined (use with a type guard) |
Type-safe null filtering:
import { filter, OperatorFunction } from 'rxjs';
export function isNotNull<T>(): OperatorFunction<T | null | undefined, T> { return filter((value): value is T => value !== null && value !== undefined);}Error handling
Section titled “Error handling”catchError — position matters
Section titled “catchError — position matters”// ❌ The error kills the outer stream: valueChanges stops working foreverthis.searchControl.valueChanges.pipe( switchMap((term) => this.api.search(term)), catchError(() => of([])),);
// ✅ The error is contained inside the inner observablethis.searchControl.valueChanges.pipe( switchMap((term) => this.api.search(term).pipe(catchError(() => of([]))), ),);This is the number one RxJS bug in Angular applications: a single failed request silently disables a search box for the rest of the session.
retry with exponential backoff
Section titled “retry with exponential backoff”this.api.getData().pipe( retry({ count: 3, delay: (error, retryCount) => { if (error.status < 500) { throw error; // do not retry client errors } return timer(Math.pow(2, retryCount) * 1000); }, }),);finalize — cleanup that always runs
Section titled “finalize — cleanup that always runs”this.loading.set(true);
this.api.save(data).pipe( finalize(() => this.loading.set(false)),).subscribe();finalize runs on complete, error and unsubscription — unlike putting the cleanup in both next and error.
Quick decision flowchart
Section titled “Quick decision flowchart”Do I need the previous request's result?├─ No, only the latest matters ............ switchMap└─ Yes ├─ Order matters ....................... concatMap ├─ Order does not matter ............... mergeMap (with concurrency) └─ Ignore new while busy ............... exhaustMapAm I combining values?├─ React to any change ................... combineLatest (or computed)├─ Wait for all to complete once ......... forkJoin├─ React to one, read the others .......... withLatestFrom└─ Many triggers, one output ............. merge