switchMap vs concatMap vs mergeMap vs exhaustMap: Picking an RxJS Policy for Overlapping Work
switchMap, concatMap, mergeMap and exhaustMap differ only in what happens when a new value arrives mid-flight. A typeahead example, the cancellation caveat, and three ways to verify the behavior in your installed RxJS version.
02 Apr 2026, 20:35 UTC

Three keystrokes in a search box can put three requests in flight at once. The interesting question is not how to fire them — it is what should happen to the second and third when the first has not finished. RxJS answers that with its higher-order mapping operators, and each one encodes a different policy about overlapping async work.
The decision is a policy, not a preference
A higher-order mapping operator takes each value from an outer observable, maps it to an inner observable, and flattens the inner results back into a single stream. "Higher-order" simply means the mapped value is itself an observable. The four operators below behave identically until a new outer value arrives while an inner subscription is still active — that is the moment they diverge.
| Operator | When a new outer value arrives mid-flight | Typical fit |
|---|---|---|
switchMap | Unsubscribes the previous inner observable; only the latest can emit | Typeahead search, route-parameter loads |
concatMap | Buffers the outer value and subscribes after the current inner completes | Sequential writes, ordered uploads |
mergeMap | Subscribes immediately; all inners run concurrently | Independent parallel tasks |
exhaustMap | Ignores the new outer value entirely while an inner is active | Submit buttons, login flows |
Two properties are easy to overlook. concatMap queues outer values with no built-in limit, so a slow inner blocks everything behind it — head-of-line blocking, and a buffer that grows with input. mergeMap accepts a concurrency number as its second argument; without it, in-flight work is unbounded and can push a backend harder than intended. exhaustMap protects against duplicate submits, but it also silently drops rapid repeat clicks, which is a product decision as much as a technical one.
Worked example: a typeahead that discards stale results
The snippet below assumes RxJS 7.x, where operators are imported from rxjs. On RxJS 6.x the operator imports come from rxjs/operators instead — check package.json or your lockfile before copying. Run it in your existing project or a scratch TypeScript file compiled with your current setup; it changes no state on its own.
import { fromEvent, of } from 'rxjs';
import {
map, debounceTime, distinctUntilChanged, switchMap, catchError,
} from 'rxjs';
// search(term) is a placeholder returning Observable<Result[]>.
// In Angular this would typically be an HttpClient GET.
const input = document.querySelector<HTMLInputElement>('#search');
fromEvent(input, 'input')
.pipe(
map((event) => (event.target as HTMLInputElement).value),
debounceTime(300),
distinctUntilChanged(),
switchMap((term) =>
search(term).pipe(
catchError(() => of([])),
),
),
)
.subscribe((results) => render(results));
The placement of catchError is the part worth copying deliberately. Inside the inner pipe, a failed request is converted into an empty result and the outer stream stays alive, so the next keystroke still searches. If catchError sat at the outer level instead, the first error would complete or terminate the outer subscription and the search box would go dead until the page reloaded.
debounceTime and distinctUntilChanged reduce how often the policy is even exercised, but they do not replace it. A user who types faster than the network can respond will still produce overlapping requests.
What switchMap does not cancel
Cancellation in RxJS is cooperative: unsubscribing tells the inner source to tear down, and whether real work stops depends on that source. An Angular HttpClient request on the default XHR backend aborts the underlying request on unsubscribe. A promise-backed inner does not — from(somePromise) cannot be cancelled, so the request completes on the server and the result is merely discarded. switchMap changes which result you observe; it does not guarantee the server stops working. If wasted backend work matters, that is a separate problem to solve (server-side debouncing, caching, or a cancellable transport).
How to verify the behavior in your installed version
Operator availability and deprecations differ across RxJS majors, so confirm against the version you actually have rather than from memory. Three checks are cheap:
- Minimal reproduction. Use a
Subjectas the outer source and an inner observable that logs on subscribe and returns a teardown function that logs on unsubscribe. Push two values in quick succession and watch which teardowns fire and when. Expected check: withswitchMap, the first inner's teardown runs as soon as the second value arrives; withconcatMap, it runs only after the first inner completes. - Marble testing.
TestSchedulerlets you assert both emitted values and inner subscription timing deterministically, which is more reliable than eyeballing logs. - Network panel. In a browser, watch whether superseded requests are actually aborted or merely ignored. This is the only one of the three that distinguishes cancellation from result discarding.
Also test the failure path explicitly: make the inner observable error and confirm the outer stream survives. That verifies the catchError placement rather than assuming it.
The trade-off, stated plainly
switchMap buys responsiveness and pays in wasted server work and possibly dropped results the user wanted. concatMap guarantees delivery and pays in latency, with an unbounded queue behind a slow inner. mergeMap maximizes throughput and pays in ordering and load. exhaustMap prevents duplicates and pays in silently ignored input.
None of these is the default answer. The right move is to choose per operation — a search box and a payment submission deserve different policies — and to write the reason down next to the operator, because the next person to read that pipe will otherwise assume the choice was arbitrary.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.