Higher-Order Mapping: Choosing Between switchMap, mergeMap, concatMap and exhaustMap
Use switchMap for reads where only the latest value matters, such as search; concatMap for writes whose order matters; exhaustMap for actions that must not repeat; and a limited mergeMap for independent work. We compare the four operators with the same marble, run them in virtual time on Bookstore's search box, cart quantity, double-clicked Place Order button and 50 cover downloads, and show why switchMap is dangerous for POSTs.
Version note: The examples in this post were run with RxJS 7.8.2, TypeScript 6.0 and Node.js 24; the time-based examples ran in virtual time with RxJS's TestScheduler.
Predict First
In the search box of Bookstore, the online bookstore used as the shared example across this site's series, a user types "du", "dun" and "dune", 150 ms apart. Every term starts a search request. A short term matches more books, so the API answers it more slowly: "du" takes 600 ms, "dun" 200 ms, "dune" 100 ms. The code looks like this:
terms$.pipe(mergeMap((term) => searchBooks(term))).subscribe(showResults);
The requests go out at 0, 150 and 300 ms. Whose results stay on the screen in the end? What would change if we wrote switchMap instead of mergeMap?
Every operator in the previous post turned one value into another. This post is about the cases where a value starts an Observable: a search term turns into an HTTP request, a "Place Order" click into a POST, a list of covers into fifty image downloads. RxJS has four core operators for this: switchMap, mergeMap, concatMap and exhaustMap. Which one you choose directly determines what the user sees on the screen and what happens on the server.
Inner Observables and the Flattening Strategy
If you write map((term) => searchBooks(term)), what you get is not search results but an Observable per term: Observable<Observable<SearchResult>>. The outside stream is called the outer Observable, the stream each value produces the inner Observable. Higher-order mapping operators subscribe to the inner Observables themselves and "flatten" their values into a single stream. The only difference between them is the answer to one question: what happens when a new outer value arrives while the previous inner Observable has not finished yet?
| Operator | When a new value arrives | Typical use |
|---|---|---|
switchMap | Cancels the previous one (unsubscribe) and switches to the new one | Search, filters, loading data for a route parameter |
mergeMap | Runs it in parallel with the previous one; can be limited with concurrent | Independent work: bulk downloads, logging |
concatMap | Queues it and starts it when the previous one finishes | Write operations whose order matters |
exhaustMap | Ignores new values until the previous one finishes | Double clicks, login, "Place Order" |
Let's see all four side by side with the same input. The showMarbles helper from the previous post produces and verifies the output on RxJS's TestScheduler here too. The outer stream emits a at frame 1 and b at frame 4; each starts an inner Observable that emits two values and lasts 7 frames (a's emits A, b's emits B):
import { concatMap, exhaustMap, mergeMap, switchMap } from "rxjs";
import { showMarbles } from "../../tools/marble.ts";
// the same outer stream and the same inner Observable for all four operators:
// value a starts an inner stream that emits "A" twice, value b one that emits "B" twice
const outer = "-a--b---------|";
const inner = "--X---X|";
showMarbles("mergeMap", (cold) => cold<string>(outer).pipe(mergeMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("concatMap", (cold) => cold<string>(outer).pipe(concatMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("switchMap", (cold) => cold<string>(outer).pipe(switchMap((v) => cold(inner, { X: v.toUpperCase() }))));
showMarbles("exhaustMap", (cold) => cold<string>(outer).pipe(exhaustMap((v) => cold(inner, { X: v.toUpperCase() }))));
mergeMap
in : -a--b---------|
in : --X---X| X=A
in : --X---X| X=B
out: ---A--BA--B---|
concatMap
in : -a--b---------|
in : --X---X| X=A
in : --X---X| X=B
out: ---A---A--B---B|
switchMap
in : -a--b---------|
in : --X---X| X=A
in : --X---X| X=B
out: ---A--B---B---|
exhaustMap
in : -a--b---------|
in : --X---X| X=A
out: ---A---A------|
The four "out" lines sum up the four strategies. With mergeMap the As and Bs interleave: the two inner streams run at the same time. With concatMap the Bs only start after a's inner stream has finished (frame 8), which makes it the line that ends last. With switchMap, a's inner stream was cancelled when b arrived; the second A never came. With exhaustMap it was the other way round: b, arriving while a's inner stream was still running, was ignored and no B was produced. You cannot say that no operator loses values; switchMap and exhaustMap lose them on purpose, and the question is which loss is the right one for your scenario.
Running Time-Based Examples in Virtual Time
The scenarios in this post would take seconds in real time, and the milliseconds would wobble a little on every run. Instead, the snapshot's tools/virtual-time.ts runs each scenario in the run mode of TestScheduler. In this mode everything time-based, such as timer and delay, uses virtual milliseconds: a 5-second scenario finishes instantly and prints the same times on every run. log prefixes each line with the current virtual time, and emitAt builds an Observable that emits values at the given moments. The fake HTTP call looks like this:
// A fake HTTP call for virtual-time examples: logs the request, answers after latencyMs,
// and logs a cancellation if the subscriber unsubscribes before the answer.
// onServer (optional) is what the server does as soon as the request arrives.
import { Observable, timer } from "rxjs";
import { log } from "../../tools/virtual-time.ts";
export function fakeRequest<T>(label: string, latencyMs: number, answer: () => T, onServer?: () => void): Observable<T> {
return new Observable<T>((subscriber) => {
log(`-> ${label}`);
onServer?.();
let answered = false;
const pending = timer(latencyMs).subscribe(() => {
answered = true;
log(`<- ${label}`);
subscriber.next(answer());
subscriber.complete();
});
return () => {
if (!answered) log(`x ${label} cancelled`);
pending.unsubscribe();
};
});
}
It is the virtual-time sibling of the getStock we wrote in the first post: -> means the request went out, <- that the answer came back, x that the request was cancelled before the answer. The cancellation log in the teardown corresponds to an unsubscribe cancelling the request in Angular's HttpClient.
The Search Box: switchMap
Let's run the scenario from the prediction with all four operators:
import { concatMap, exhaustMap, mergeMap, switchMap, type OperatorFunction } from "rxjs";
import { books } from "../../data/books.ts";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";
type SearchResult = { term: string; titles: string[] };
// a short term matches more books, so the API answers it more slowly
const latency: Record<string, number> = { du: 600, dun: 200, dune: 100 };
function searchBooks(term: string) {
return fakeRequest(`GET /books?q=${term}`, latency[term], (): SearchResult => ({
term,
titles: books.filter((b) => b.title.toLowerCase().includes(term)).map((b) => b.title),
}));
}
function runSearch(name: string, flatten: OperatorFunction<string, SearchResult>) {
runInVirtualTime(name, () => {
// the user types "du", "dun", "dune" 150 ms apart
const terms$ = emitAt([0, "du"], [150, "dun"], [300, "dune"]);
terms$.pipe(flatten).subscribe((r) => log(`screen: "${r.term}" -> ${r.titles.join(", ")}`));
});
}
runSearch("mergeMap", mergeMap((term) => searchBooks(term)));
runSearch("concatMap", concatMap((term) => searchBooks(term)));
runSearch("switchMap", switchMap((term) => searchBooks(term)));
runSearch("exhaustMap", exhaustMap((term) => searchBooks(term)));
--- mergeMap
0 ms -> GET /books?q=du
150 ms -> GET /books?q=dun
300 ms -> GET /books?q=dune
350 ms <- GET /books?q=dun
350 ms screen: "dun" -> Dune
400 ms <- GET /books?q=dune
400 ms screen: "dune" -> Dune
600 ms <- GET /books?q=du
600 ms screen: "du" -> Dune
--- concatMap
0 ms -> GET /books?q=du
600 ms <- GET /books?q=du
600 ms screen: "du" -> Dune
600 ms -> GET /books?q=dun
800 ms <- GET /books?q=dun
800 ms screen: "dun" -> Dune
800 ms -> GET /books?q=dune
900 ms <- GET /books?q=dune
900 ms screen: "dune" -> Dune
--- switchMap
0 ms -> GET /books?q=du
150 ms x GET /books?q=du cancelled
150 ms -> GET /books?q=dun
300 ms x GET /books?q=dun cancelled
300 ms -> GET /books?q=dune
400 ms <- GET /books?q=dune
400 ms screen: "dune" -> Dune
--- exhaustMap
0 ms -> GET /books?q=du
600 ms <- GET /books?q=du
600 ms screen: "du" -> Dune
The answer to the prediction is in the mergeMap block: the results for "du" stayed on the screen last. The user typed "dune", but because the slowest request came back last, at 600 ms the screen was updated with the results of an old term. This is a race condition, and it usually does not show up in a test environment, because with a local API requests always come back at the same speed. In Bookstore's catalog of 40,000 books, though, the difference in time between a broad and a narrow search produces exactly this order in production.
concatMap showed the right result, but at 900 ms; the user waited for two searches they no longer cared about. exhaustMap is the worst: it ignored the terms that arrived until "du" finished, and "dune" was never searched. switchMap cancelled the previous request on every new term (the x lines) and showed only the latest term's result, at 400 ms. switchMap is the operator for "only the latest value matters" scenarios such as search, filters and route parameters. A real search box puts a debounceTime in front of it; the time operators are in this series' post on time.
Changing Quantities in the Cart: concatMap
On a cart line the user increases Dune's quantity three times with "+": 2, 3, 4. Every click sends a PUT request. Because of network jitter, in this run the later requests are answered faster:
import { concatMap, mergeMap, type OperatorFunction } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";
// network jitter: in this run the later requests happen to be answered faster
const latency: Record<number, number> = { 2: 300, 3: 200, 4: 100 };
function setQuantity(quantity: number) {
return fakeRequest(`PUT /cart/items/9780441172719 quantity=${quantity}`, latency[quantity], () => quantity);
}
function runStepper(name: string, flatten: OperatorFunction<number, number>) {
runInVirtualTime(name, () => {
// the user clicks "+" on Dune three times: 1 -> 2 -> 3 -> 4
const quantities$ = emitAt([0, 2], [50, 3], [100, 4]);
quantities$.pipe(flatten).subscribe((q) => log(`cart line shows quantity ${q}`));
});
}
runStepper("mergeMap", mergeMap((q) => setQuantity(q)));
runStepper("concatMap", concatMap((q) => setQuantity(q)));
--- mergeMap
0 ms -> PUT /cart/items/9780441172719 quantity=2
50 ms -> PUT /cart/items/9780441172719 quantity=3
100 ms -> PUT /cart/items/9780441172719 quantity=4
200 ms <- PUT /cart/items/9780441172719 quantity=4
200 ms cart line shows quantity 4
250 ms <- PUT /cart/items/9780441172719 quantity=3
250 ms cart line shows quantity 3
300 ms <- PUT /cart/items/9780441172719 quantity=2
300 ms cart line shows quantity 2
--- concatMap
0 ms -> PUT /cart/items/9780441172719 quantity=2
300 ms <- PUT /cart/items/9780441172719 quantity=2
300 ms cart line shows quantity 2
300 ms -> PUT /cart/items/9780441172719 quantity=3
500 ms <- PUT /cart/items/9780441172719 quantity=3
500 ms cart line shows quantity 3
500 ms -> PUT /cart/items/9780441172719 quantity=4
600 ms <- PUT /cart/items/9780441172719 quantity=4
600 ms cart line shows quantity 4
With mergeMap the cart line counted backwards, 4 → 3 → 2, and stayed at 2; the user had asked for 4. Worse, on a real network the order in which three parallel PUTs reach the server is not guaranteed either, so the value on the server may end up as 2 or 3 as well. concatMap sent each request after the previous one's answer: the requests went out in order, the screen went 2, 3, 4, and the server and the screen stayed in sync. The price is time: the last value came at 600 ms. That price is worth paying for write operations whose order matters (quantity updates, sequential steps, consecutive PATCHes to the same record). concatMap behaves the same as mergeMap(project, 1); the queue in between lives in memory, so if the user clicks very fast, the queue grows too.
Double-Clicking "Place Order": exhaustMap
Ada places her 770 TRY order (Dune and Emma) and, out of habit, double-clicks the button, then presses it once more. The server creates the order and charges the payment as soon as the request arrives, and answers 500 ms later:
import { defer, exhaustMap, mergeMap, switchMap, type OperatorFunction } from "rxjs";
import { emitAt, log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";
let nextOrderId = 1042;
// the server creates the order as soon as the request arrives, then answers after 500 ms
function placeOrder() {
return defer(() => {
const orderId = nextOrderId++;
return fakeRequest(`POST /orders (order ${orderId})`, 500, () => orderId, () =>
log(` server: order ${orderId} created for Ada, 770 TRY charged`),
);
});
}
function runCheckout(name: string, flatten: OperatorFunction<string, number>) {
nextOrderId = 1042;
runInVirtualTime(name, () => {
// Ada double-clicks "Place Order", then clicks once more
const clicks$ = emitAt([0, "click"], [80, "click"], [160, "click"]);
clicks$.pipe(flatten).subscribe((id) => log(`screen: thank you, order ${id}`));
});
}
runCheckout("mergeMap", mergeMap(() => placeOrder()));
runCheckout("switchMap", switchMap(() => placeOrder()));
runCheckout("exhaustMap", exhaustMap(() => placeOrder()));
--- mergeMap
0 ms -> POST /orders (order 1042)
0 ms server: order 1042 created for Ada, 770 TRY charged
80 ms -> POST /orders (order 1043)
80 ms server: order 1043 created for Ada, 770 TRY charged
160 ms -> POST /orders (order 1044)
160 ms server: order 1044 created for Ada, 770 TRY charged
500 ms <- POST /orders (order 1042)
500 ms screen: thank you, order 1042
580 ms <- POST /orders (order 1043)
580 ms screen: thank you, order 1043
660 ms <- POST /orders (order 1044)
660 ms screen: thank you, order 1044
--- switchMap
0 ms -> POST /orders (order 1042)
0 ms server: order 1042 created for Ada, 770 TRY charged
80 ms x POST /orders (order 1042) cancelled
80 ms -> POST /orders (order 1043)
80 ms server: order 1043 created for Ada, 770 TRY charged
160 ms x POST /orders (order 1043) cancelled
160 ms -> POST /orders (order 1044)
160 ms server: order 1044 created for Ada, 770 TRY charged
660 ms <- POST /orders (order 1044)
660 ms screen: thank you, order 1044
--- exhaustMap
0 ms -> POST /orders (order 1042)
0 ms server: order 1042 created for Ada, 770 TRY charged
500 ms <- POST /orders (order 1042)
500 ms screen: thank you, order 1042
With mergeMap, Ada's card was charged 770 TRY three times and three thank-you messages appeared on screen. switchMap is sneakier: the screen shows a single order (1044), as if everything worked. But the server log has three orders. The client cancelled the first two requests, meaning it stopped listening for their answers; the server had already received and processed them. Unsubscribing does not take back an HTTP request that has been sent; at most it stops waiting for the answer. That is why switchMap is dangerous for requests with side effects such as POST, PUT and DELETE: it leaves half-finished or repeated work on the server without the user seeing anything.
exhaustMap took the first click and ignored the two clicks that arrived while the request was running: one order, one payment. It is the operator for every action that must not be repeated while it is in progress, such as "Place Order", "Sign In" and "Confirm Payment". Still, do not leave it as the only line of defense: repeats coming from another tab, a retry or a network drop also need to be blocked on the server with an idempotency key; that mechanism is explained in the post "Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries".
50 Covers, At Most 4 in Parallel: mergeMap(…, 4)
The admin screen loads the cover images of 50 books. The jobs are independent of each other and their order does not matter; this is mergeMap's territory. But unlimited parallelism is a problem too:
import { concatMap, defer, finalize, map, mergeMap, range, timer, type OperatorFunction } from "rxjs";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
let active = 0;
let maxActive = 0;
// loading one cover image takes 100 ms; active/maxActive count parallel downloads
function loadCover(index: number) {
return defer(() => {
active++;
maxActive = Math.max(maxActive, active);
return timer(100).pipe(
map(() => `cover-${index}.jpg`),
finalize(() => active--),
);
});
}
function runCovers(name: string, flatten: OperatorFunction<number, string>) {
active = 0;
maxActive = 0;
runInVirtualTime(name, () => {
let loaded = 0;
range(1, 50)
.pipe(flatten)
.subscribe({
next: () => loaded++,
complete: () => log(`${loaded} covers loaded, at most ${maxActive} at the same time`),
});
});
}
runCovers("mergeMap (no limit)", mergeMap((i) => loadCover(i)));
runCovers("mergeMap(..., 4)", mergeMap((i) => loadCover(i), 4));
runCovers("concatMap", concatMap((i) => loadCover(i)));
--- mergeMap (no limit)
100 ms 50 covers loaded, at most 50 at the same time
--- mergeMap(..., 4)
1300 ms 50 covers loaded, at most 4 at the same time
--- concatMap
5000 ms 50 covers loaded, at most 1 at the same time
Unlimited mergeMap started all 50 requests at once and finished in 100 ms of virtual time. In the real world that number is misleading: over HTTP/1.1 the browser opens a limited number of connections to the same host, so the requests wait in the browser's queue anyway, and the server and the CDN may rate-limit 50 simultaneous requests. Beyond that, the first covers the user sees slow down in the crowd. concatMap is the other extreme: one at a time, 5 seconds. mergeMap's second parameter, concurrent, gives you the middle ground: at most 4 parallel requests, 1.3 seconds. The right number is found by measuring; the server's rate limit and the browser's connection behavior (many requests over one connection with HTTP/2) decide it.
Fetching a Paged API to the End: expand
Sometimes the parameter for the next request comes inside the previous answer: a paged API says "the next page starts at this offset". expand is a mergeMap that feeds itself: it emits every value and also produces a new inner Observable from that value:
import { EMPTY, expand, reduce } from "rxjs";
import { books } from "../../data/books.ts";
import { log, runInVirtualTime } from "../../tools/virtual-time.ts";
import { fakeRequest } from "./fake-api.ts";
type CatalogPage = { items: string[]; nextOffset: number | null };
// the catalog API returns 2 books per page and tells us where the next page starts
function getCatalogPage(offset: number) {
return fakeRequest(`GET /books?offset=${offset}&limit=2`, 100, (): CatalogPage => ({
items: books.slice(offset, offset + 2).map((b) => b.title),
nextOffset: offset + 2 < books.length ? offset + 2 : null,
}));
}
runInVirtualTime("expand", () => {
getCatalogPage(0)
.pipe(
expand((page) => (page.nextOffset === null ? EMPTY : getCatalogPage(page.nextOffset))),
reduce((all, page) => [...all, ...page.items], [] as string[]),
)
.subscribe((titles) => log(`${titles.length} books: ${titles.join(", ")}`));
});
--- expand
0 ms -> GET /books?offset=0&limit=2
100 ms <- GET /books?offset=0&limit=2
100 ms -> GET /books?offset=2&limit=2
200 ms <- GET /books?offset=2&limit=2
200 ms -> GET /books?offset=4&limit=2
300 ms <- GET /books?offset=4&limit=2
300 ms 6 books: Dune, Emma, The Hobbit, Middlemarch, Frankenstein, Pride and Prejudice
The stop condition is EMPTY: when nextOffset comes back as null on the last page, no new request is produced, expand completes once the inner streams finish, and the reduce from the previous post collects all pages into a single list. This is the right place for reduce: the stream really ends. If you forget the stop condition (or the API mistakenly returns an offset on the last page), expand sends requests forever; an upper bound in production (such as take(100)) is cheap insurance.
In Production
The symptoms of a wrong flattening strategy are usually "every now and then", so you need to measure. In the search box, check in the DevTools Network tab how many search requests are pending at the same time; requests cancelled by switchMap show up with the "(canceled)" status, while piled-up pending requests are a sign of the wrong operator. Slowing the network down (Network throttling with "Slow 4G") makes the race condition visible on a developer machine too.
For write operations the real measurement is on the server side: two orders created a few hundred milliseconds apart for the same customer, or update logs whose order is mixed up. A simple report on Bookstore's order table that counts orders "for the same customer, with the same amount, less than a second apart" exposes a wrong mergeMap or a missing exhaustMap on the client right away.
A short rule for choosing: first check whether the work is a read or a write. Cancelling reads (GET) is safe, so if "only the latest value matters" use switchMap, and if they all matter use a limited mergeMap. Cancelling writes is not safe: if order matters use concatMap, if repeats are unwanted use exhaustMap. Before putting switchMap on a write, make sure you know how the server handles a half-finished request.
Common Pitfalls
- Using
mergeMapfor search or filters. Answers come back out of order, and the slowest old request updates the screen last. - Using
switchMapfor POST, PUT and DELETE requests. Cancellation is client-side only; the server may already have processed the request. - Starting hundreds of requests with unlimited
mergeMap. Set an upper bound with theconcurrentparameter. - Using
exhaustMaporconcatMapin a search box. The user's latest term is either never searched or waits for searches they no longer care about. - Forgetting the stop condition (
EMPTY) inexpand, or relying only on the API to answer correctly. - Writing
subscribeinsidesubscribeinstead of a higher-order operator. Nobody cancels the inner subscription and the strategy in use cannot be read from the code; it is an unlimited, uncontrolledmergeMap.
A Small Experiment
★☆☆ In 01-search.ts, change the latencies to { du: 100, dun: 200, dune: 600 }. Predict the output of all four operators first, then run it. Which operator's result changed, and why did switchMap's not?
★★☆ Add a fourth scenario to 04-place-order.ts: concatMap. How many orders are created on the server for Ada's three clicks? Then add a log("button disabled") and a log("button enabled") to exhaustMap to disable the button while the request is running; explain where in the stream you put them and why not inside subscribe.
★★★ Change loadCover in 05-covers.ts so that every 10th cover is a "large image" that takes 300 ms. With mergeMap(…, 4), how do the total time and the order in which covers load change? If you still wanted to show the covers on screen in order from 1 to 50, which operator would you choose and what would you lose in parallelism?
In this post we produced a new stream from every value of a single stream. In the next post we combine different streams: we turn the category, price range and sort filters on the catalog page into a single query with combineLatest, make three API calls in parallel on the order details page with forkJoin, and look at which question withLatestFrom, zip, merge, concat and race each answer.