Skip to content
Angular ng rxjs 4 min read

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.

OperatorOn a new outer valueUse when
switchMapCancels the in-flight inner, switches to the new oneOnly the latest result matters (search, navigation)
mergeMapRuns inners concurrently, interleaves resultsIndependent tasks, order doesn’t matter
concatMapQueues inners, runs one at a time in orderOrder matters, no overlap allowed
exhaustMapIgnores new outer values until the current inner completesPrevent 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 switchMap for 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 switchMap by default on read operations where only the latest result matters; pair it with debounceTime and distinctUntilChanged for search inputs.
  • Use concatMap or exhaustMap — never switchMap — for writes, so an in-flight mutation is not silently cancelled.
  • Bound mergeMap concurrency 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 leave exhaustMap permanently “busy”.
  • Add takeUntilDestroyed() (or toSignal) so the outer subscription, and any active inner, are torn down with the component.
  • Move error handling onto the inner observable with catchError when you want a failure of one inner to be recoverable without killing the outer stream.
Last updated June 14, 2026
Was this helpful?