Flattening Operators
When one observable needs to trigger another — a keystroke that fires an HTTP request, a button click that starts a save — you end up with an observable of observables. These are higher-order observables, and you almost never want to subscribe to them manually. Flattening operators solve this by subscribing to the inner observable for you and emitting its values on the outer stream. The four you reach for daily are switchMap, mergeMap, concatMap, and exhaustMap, and the only real difference between them is how they handle a new outer value while an inner observable is still running.
The core idea
A flattening operator maps each outer value to an inner observable, then merges the inner emissions back into a single flat stream. Without flattening you’d get nested Observable<Observable<T>> and a subscribe inside a subscribe — an anti-pattern that leaks subscriptions and breaks cancellation.
import { fromEvent, switchMap } from 'rxjs';
import { ajax } from 'rxjs/ajax';
const clicks$ = fromEvent(document, 'click');
// Each click maps to an inner HTTP observable; switchMap flattens it.
clicks$
.pipe(switchMap(() => ajax.getJSON('/api/now')))
.subscribe((res) => console.log(res));
The strategies differ in their concurrency behaviour. The table below summarises the decision.
| Operator | On a new outer value | Use when |
|---|---|---|
switchMap | Cancels the in-flight inner, switches to the new one | Only the latest result matters (search, navigation) |
mergeMap | Runs inners concurrently, interleaves results | Independent tasks, order doesn’t matter |
concatMap | Queues inners, runs one at a time in order | Order matters, no overlap allowed |
exhaustMap | Ignores new outer values until the current inner completes | Prevent duplicate triggers (form submits) |
switchMap — keep only the latest
switchMap unsubscribes from the previous inner observable as soon as a new outer value arrives. For HTTP this cancels the stale request, making it the default for type-ahead search where an older response must never overwrite a newer one.
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, switchMap, debounceTime, distinctUntilChanged } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input (input)="onInput($event)" placeholder="Search users…" />
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient);
private query$ = new Subject<string>();
results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) =>
this.http.get<User[]>(`/api/users?q=${encodeURIComponent(q)}`),
),
takeUntilDestroyed(),
),
{ initialValue: [] as User[] },
);
onInput(e: Event) {
this.query$.next((e.target as HTMLInputElement).value);
}
}
interface User { id: number; name: string; }
Never use
switchMapfor write operations (POST/PUT/DELETE). If a second value arrives mid-flight it cancels the first request, which may have already mutated the server, leaving you in an inconsistent state.
mergeMap — run everything in parallel
mergeMap (formerly flatMap) subscribes to every inner observable and lets them all run at once, emitting results as they arrive. Throughput is high but ordering is not guaranteed. You can cap concurrency with the optional second argument.
import { from, mergeMap } from 'rxjs';
import { ajax } from 'rxjs/ajax';
const ids$ = from([1, 2, 3, 4, 5]);
// At most 2 requests in flight at any time.
ids$
.pipe(mergeMap((id) => ajax.getJSON(`/api/item/${id}`), 2))
.subscribe((item) => console.log(item));
Output:
{ id: 2, name: 'Beta' }
{ id: 1, name: 'Alpha' }
{ id: 3, name: 'Gamma' }
{ id: 5, name: 'Epsilon' }
{ id: 4, name: 'Delta' }
Note the out-of-order results: whichever inner completes first emits first.
concatMap — preserve order, no overlap
concatMap queues each inner observable and starts the next only after the current one completes. It guarantees order and serial execution at the cost of throughput — ideal for sequential writes such as replaying a batch of edits.
import { from, concatMap, delay, of } from 'rxjs';
const tasks$ = from(['save-a', 'save-b', 'save-c']);
tasks$
.pipe(concatMap((t) => of(t).pipe(delay(200))))
.subscribe((t) => console.log('done:', t));
Output:
done: save-a
done: save-b
done: save-c
exhaustMap — ignore while busy
exhaustMap keeps the current inner observable and drops any outer values that arrive before it completes. This is the natural fit for a submit button: rapid double-clicks won’t fire duplicate requests.
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Subject, exhaustMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-save-button',
standalone: true,
template: `<button (click)="save$.next()">Save</button>`,
})
export class SaveButtonComponent {
private http = inject(HttpClient);
save$ = new Subject<void>();
constructor() {
this.save$
.pipe(
exhaustMap(() => this.http.post('/api/save', { dirty: true })),
takeUntilDestroyed(),
)
.subscribe();
}
}
Best practices
- Reach for
switchMapby default on read operations where only the latest result matters; pair it withdebounceTimeanddistinctUntilChangedfor search inputs. - Use
concatMaporexhaustMap— neverswitchMap— for writes, so an in-flight mutation is not silently cancelled. - Bound
mergeMapconcurrency with its second argument to avoid flooding the server or browser connection pool. - Keep inner observables finite; an inner that never completes will stall
concatMap’s queue and leaveexhaustMappermanently “busy”. - Add
takeUntilDestroyed()(ortoSignal) so the outer subscription, and any active inner, are torn down with the component. - Move error handling onto the inner observable with
catchErrorwhen you want a failure of one inner to be recoverable without killing the outer stream.