Ch. 4 · Angular

Choosing an RxJS Mapping Operator in Angular: Search, Save, Login

switchMap, mergeMap, concatMap or exhaustMap? One timeline of four requests shows what each operator does, and which fits search, save and login.

~6 min readadvancedupdated Oct 7, 2026

When an Angular interviewer asks “what’s the difference between switchMap and mergeMap?”, they aren’t testing whether you’ve memorised the RxJS docs. They’re checking whether you can look at a user action, say a search box, a save button or a login form, and pick the behaviour that’s correct for it. The four higher-order mapping operators do the same job (turn each outer value into an inner observable and flatten the results). The only thing that differs is their policy when a new outer value arrives while an inner one is still running.

This note puts all four on the same timeline so you can see the policies side by side, then maps them to real Angular features. The timeline was produced with RxJS 7.8’s TestScheduler, so the timings are exact rather than approximate.

One timeline, four operators

Imagine a button that triggers an HTTP request. The user clicks four times: at 0, 10, 20 and 60 ms. Request #1 is slow and takes 50 ms; the others take 30 ms. Each click is mapped to its request with one of the operators:

clicks$.pipe(
  op((n) => request(n)), // op = switchMap | mergeMap | concatMap | exhaustMap
);
TypeScript

Here’s what started when, and which responses reached the subscriber:

operator    started                  responses
switchMap   #1@0 #2@10 #3@20 #4@60   #3@50 #4@90
mergeMap    #1@0 #2@10 #3@20 #4@60   #2@40 #1@50 #3@50 #4@90
concatMap   #1@0 #2@50 #3@80 #4@110  #1@50 #2@80 #3@110 #4@140
exhaustMap  #1@0 #4@60               #1@50 #4@90
Text

Each row is a different answer to “a new click arrived, and I’m still busy”.

switchMap: cancel and replace

switchMap starts #1, then when #2 arrives at 10 ms it unsubscribes from #1 and starts #2. Same again for #3. Only #3 and #4 were allowed to finish, so the subscriber saw exactly two responses, and they were the latest ones.

That’s the right policy when only the newest request matters and older ones are obsolete:

  • Typeahead search. The results for “ang” are useless once the user has typed “angular”.
  • Loading data for the current route parameter. When the id changes from 1 to 2, nobody wants dashboard 1 anymore.
  • Refreshing a view on filter change.
results = toSignal(
  this.query$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => this.http.get<Product[]>('/api/search', { params: { q } })),
  ),
  { initialValue: [] },
);
TypeScript

With HttpClient, unsubscribing aborts the underlying request, so the browser stops waiting for it. That’s why switchMap prevents the classic race where a slow old response overwrites a fast new one.

mergeMap: run everything in parallel

mergeMap started all four immediately and delivered every response, in completion order: #2 at 40 ms, then #1 at 50 ms. If those were search results, the screen would show “ang” results after “angula” results. That’s the race condition people accidentally build when they use mergeMap (or nest subscribe() calls, which behaves the same way) for reads.

mergeMap is correct when the requests are independent and you need all of them, and order doesn’t matter: uploading several files at once, sending analytics events, or fetching details for each id in a list. It takes a concurrency limit as a second argument, mergeMap(fn, 3), which is useful when you’d otherwise fire 200 requests at once.

concatMap: queue in order

concatMap started #1, then waited. #2 started only when #1 finished at 50 ms, #3 when #2 finished, and so on. Every response arrived, in click order, and the last one came at 140 ms instead of 90.

That’s the policy for writes where order matters:

  • Autosaving a document, where save #3 must not reach the server before save #2, or the older content wins.
  • A sequence of commands, like “add item” then “apply coupon”.

The cost is latency: a slow request delays everything behind it, and if the user produces values faster than the server responds, the queue grows. For autosave you’d usually debounceTime first so the queue stays short.

exhaustMap: ignore while busy

exhaustMap started #1, then ignored #2 and #3 because #1 was still running. #4 arrived at 60 ms, after #1 finished, so it ran. Two requests in total.

That’s the policy for actions that shouldn’t be repeated while in progress:

  • A login or payment button where double clicks must not submit twice.
  • “Load more” on scroll, where scroll events fire constantly but you want one page at a time.
  • A manual refresh button.

exhaustMap is the operator most candidates forget, and it’s often the right answer for form submission. Disabling the button while saving is still good UX, but exhaustMap makes the stream itself safe even if the button state is wrong.

The mistake interviewers listen for: switchMap on writes

switchMap cancels the subscription. It cannot cancel the server’s work. When the browser aborts an HTTP request, the request may already have reached the server, and the server will usually finish processing it. So with:

this.save$.pipe(switchMap((doc) => this.http.put(`/api/docs/${doc.id}`, doc)));
TypeScript

a fast second save cancels the first one on the client, but both may be applied on the server, in whatever order they arrive. The client also never sees the first response, so errors from it are lost. For writes, choose concatMap (ordered) or exhaustMap (no repeats). If you hear “use switchMap for the save button” in an interview, that’s the answer to push back on.

A smaller trap: error handling. If an inner request errors and you don’t catch it inside the mapping function, the error kills the outer stream, and the search box stops working until reload.

switchMap((q) =>
  this.http.get<Product[]>('/api/search', { params: { q } }).pipe(
    catchError(() => of([])), // inner catch: the outer stream survives
  ),
)
TypeScript

Putting catchError after switchMap, on the outer stream, replaces the whole stream with the fallback and completes it. The note on HTTP error handling and retry has more on where to put retries.

Choosing quickly

When you’re unsure, ask one question about the user action: if a new one arrives while the last is still running, what should happen to the last one?

  • It’s obsolete: switchMap.
  • It still matters and order doesn’t: mergeMap.
  • It still matters and order does: concatMap.
  • The new one is the redundant one: exhaustMap.

Signals don’t replace this decision. resource() and httpResource() (experimental in Angular 19 and 20) behave like switchMap for loading data: a new request value cancels the previous load. For writes and user commands you still pick an operator, or write the equivalent policy yourself.

Interview practice

A notes app autosaves each note: every keystroke pushes the current content into content$, and the code is content$.pipe(debounceTime(500), switchMap(c => this.api.save(c))). Occasionally users reopen a note and find text from a few seconds before their last edit. Explain the cause and fix it.

Answer

switchMap cancels the previous save on the client when a new one is emitted, but cancellation only aborts the browser’s wait for a response. If save A was already on its way, the server still processes it. Now save A and save B are both in flight, and nothing guarantees which one the server applies last. If A is slower (a bigger payload, a different server instance, a retry), A lands after B and overwrites the newer content. The debounce makes it rarer, which is why the bug is “occasional”.

The fix is to serialise the writes: concatMap(c => this.api.save(c)) guarantees save B is sent only after save A’s response arrives, so the server sees them in order. Keep the debounce, so the queue holds one or two snapshots rather than one per keystroke. For extra safety I’d also send a version number or If-Match header so the server can reject an out-of-date write, because ordering on the client doesn’t protect against two open tabs.

Further reading

More in Angular

esc