← Back to Articles         
RxJS catchError(), switchMap(), mergeMap(), concatMap()

RxJS catchError(), switchMap(), mergeMap(), concatMap()

Published on 01 Oct 2026     13 min read Angular
RxJS & Observables

RxJS catchError(), switchMap(), mergeMap(), concatMap() and exhaustMap()

These operators are especially important in Angular because they are commonly used with HTTP requests, forms, search boxes, button clicks, and dependent API calls.

First, remember this summary:

Operator Main purpose
catchError() Handle/recover from errors
switchMap() Cancel previous, use latest
mergeMap() Run all concurrently
concatMap() Run one-by-one in order
exhaustMap() Ignore new requests while current one runs

The last four are often called higher-order mapping/flattening operators because they take a source value, create an inner Observable, and decide how those inner Observables should be handled.


1. What Does catchError() Do?

What is catchError()?

catchError() is an RxJS operator used to intercept and handle an error from an Observable.

It can:

  • return a fallback value,
  • return another Observable,
  • log the error,
  • transform the error,
  • rethrow the error.

Simple definition

catchError() catches an Observable error and lets you decide what should happen next.


Full Simple Example

import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';

const data$ = of('Angular');

data$
  .pipe(
    catchError(error => {
      console.error('Error:', error);

      return of('Default Value');
    })
  )
  .subscribe(value => {
    console.log(value);
  });

There is no error here, so output is:

Angular

Now consider an actual HTTP example.

import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { catchError, Observable, of } from 'rxjs';

export interface Product {
  id: number;
  name: string;
  price: number;
}

@Injectable({
  providedIn: 'root'
})
export class ProductService {

  private http = inject(HttpClient);

  getProducts(): Observable<Product[]> {

    return this.http
      .get<Product[]>('/api/products')
      .pipe(

        catchError(error => {

          console.error('API Error:', error);

          return of([]);

        })

      );
  }
}

If the request succeeds:

HTTP Request
     │
     ▼
Product[]
     │
     ▼
Subscriber

If it fails:

HTTP Request
     │
     ▼
   ERROR
     │
     ▼
catchError()
     │
     ▼
   of([])
     │
     ▼
Subscriber receives []

Rethrowing an Error

Sometimes you don't want to hide the error.

You can log it and rethrow it:

import { catchError, throwError } from 'rxjs';

return this.http
  .get<Product[]>('/api/products')
  .pipe(

    catchError(error => {

      console.error(error);

      return throwError(() => error);

    })

  );

Then the subscriber can still receive the error.


2. What Does switchMap() Do?

What is switchMap()?

switchMap() takes each source value and maps it to another Observable.

When a new source value arrives, it unsubscribes from the previous inner Observable and switches to the latest one.

Simple definition

switchMap() = cancel/leave the previous inner Observable and switch to the latest one.

The easiest phrase to remember is:

Latest wins.


Full switchMap() Example — Search

import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';

import { ProductService } from './product.service';
import { Product } from './product';

@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './product-search.html'
})
export class ProductSearch {

  private productService = inject(ProductService);

  searchControl = new FormControl('', {
    nonNullable: true
  });

  products: Product[] = [];

  constructor() {

    this.searchControl.valueChanges
      .pipe(

        debounceTime(300),

        distinctUntilChanged(),

        switchMap(search =>
          this.productService.searchProducts(search)
        )

      )
      .subscribe(products => {

        this.products = products;

      });
  }
}

product.service.ts

import { HttpClient, HttpParams } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';

import { Product } from './product';

@Injectable({
  providedIn: 'root'
})
export class ProductService {

  private http = inject(HttpClient);

  searchProducts(search: string): Observable<Product[]> {

    const params = new HttpParams()
      .set('search', search);

    return this.http.get<Product[]>(
      '/api/products',
      { params }
    );
  }
}

How switchMap() Works

Suppose the user types:

Lap

Request starts:

GET /api/products?search=Lap

Before it completes, the user types:

Laptop

switchMap() switches:

Lap
 │
 ▼
Request #1
 │
 X  unsubscribed


Laptop
   │
   ▼
Request #2
   │
   ▼
Result

Conceptually:

A ───────► Request A
             X

B ─────────────► Request B
                    X

C ─────────────────────► Request C
                              │
                              ▼
                            Result

Therefore switchMap() is excellent for:

Search
Autocomplete
Route parameter changes
Latest-filter requests
Dependent lookups where only latest matters

3. What Does mergeMap() Do?

What is mergeMap()?

mergeMap() maps each source value to an inner Observable and allows multiple inner Observables to execute concurrently.

It does not cancel the previous one just because a new value arrives.

Simple definition

mergeMap() = start every inner Observable and allow them to run concurrently.

Easy phrase:

All can run.


Full Simple Example

import { of, timer } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';

const requests$ = of('A', 'B', 'C');

requests$
  .pipe(

    mergeMap(value =>
      timer(1000).pipe(
        map(() => `Completed ${value}`)
      )
    )

  )
  .subscribe(result => {

    console.log(result);

  });

Conceptually:

A ─────► Request A ─────► Result A

B ─────► Request B ─────► Result B

C ─────► Request C ─────► Result C

All are allowed to run.


Angular Example

Imagine saving multiple independent items:

from(products)
  .pipe(

    mergeMap(product =>
      this.productService.saveProduct(product)
    )

  )
  .subscribe(result => {

    console.log('Saved:', result);

  });

Conceptually:

Product 1 ─────► POST ─────►
Product 2 ─────► POST ─────►
Product 3 ─────► POST ─────►

             Concurrent

Use mergeMap() when:

All operations matter and concurrency is acceptable.


4. What Does concatMap() Do?

What is concatMap()?

concatMap() maps each source value to an inner Observable but executes the inner Observables sequentially.

The next one waits until the current one completes.

Simple definition

concatMap() = process one inner Observable at a time, preserving order.

Easy phrase:

One-by-one.


Full Simple Example

import { of, timer } from 'rxjs';
import { concatMap, map } from 'rxjs/operators';

const requests$ = of('A', 'B', 'C');

requests$
  .pipe(

    concatMap(value =>
      timer(1000).pipe(
        map(() => `Completed ${value}`)
      )
    )

  )
  .subscribe(result => {

    console.log(result);

  });

Flow:

A
│
▼
Request A
│
▼
Complete
│
▼
B
│
▼
Request B
│
▼
Complete
│
▼
C
│
▼
Request C

So:

Request A ─────► Complete
                      │
                      ▼
                 Request B ─────► Complete
                                       │
                                       ▼
                                  Request C

Angular Example

Suppose three operations must happen in sequence:

from(products)
  .pipe(

    concatMap(product =>
      this.productService.saveProduct(product)
    )

  )
  .subscribe(result => {

    console.log('Saved:', result);

  });

The requests happen like:

Save Product 1
      │
      ▼
   Complete
      │
      ▼
Save Product 2
      │
      ▼
   Complete
      │
      ▼
Save Product 3

Use concatMap() when order matters.


5. What Does exhaustMap() Do?

What is exhaustMap()?

exhaustMap() starts processing the first inner Observable.

While that inner Observable is running, new source values are ignored.

After the current operation completes, it can accept another source value.

Simple definition

exhaustMap() = while busy, ignore new emissions.

Easy phrase:

Busy? Ignore new requests.


Full Angular Example — Prevent Double Submit

This is one of the best examples of exhaustMap().

Suppose the user clicks:

Submit
Submit
Submit
Submit

very quickly.

You don't want four API requests.

import { Component, inject } from '@angular/core';
import { Subject, exhaustMap } from 'rxjs';

import { OrderService } from './order.service';

@Component({
  selector: 'app-order',
  standalone: true,
  template: `
    <button (click)="submit()">
      Submit Order
    </button>
  `
})
export class Order {

  private orderService = inject(OrderService);

  private submit$ = new Subject<void>();

  constructor() {

    this.submit$
      .pipe(

        exhaustMap(() =>
          this.orderService.createOrder()
        )

      )
      .subscribe(result => {

        console.log('Order created:', result);

      });
  }

  submit(): void {

    this.submit$.next();

  }
}

Suppose the user clicks four times:

Click 1
  │
  ▼
HTTP Request
  │
  │
Click 2 ──► IGNORED
  │
Click 3 ──► IGNORED
  │
Click 4 ──► IGNORED
  │
  ▼
HTTP Complete

Now another click can be accepted:

Click 5
  │
  ▼
New HTTP Request

This makes exhaustMap() useful for things such as:

Login button
Submit button
Payment initiation
Form submission
Refresh action

when repeated triggers should be ignored while the current operation is active.


6. The Four Mapping Operators Together

This is the most important part.

Suppose values arrive:

A
B
C

and each value starts an HTTP request.


switchMap()

Latest wins

A ─────► Request A
           X

B ─────────► Request B
               X

C ─────────────► Request C
                       │
                       ▼
                    Result

Previous inner subscriptions are dropped when a newer source value arrives.

Best example

Search box

mergeMap()

All run concurrently

A ─────► Request A ─────►

B ─────► Request B ─────►

C ─────► Request C ─────►

No previous request is cancelled merely because another source value arrives.

Best example

Independent parallel operations

concatMap()

One-by-one

A ─────► Request A ─────► Complete
                              │
B ────────────────────────────┘
       Request B ─────► Complete
                            │
C ──────────────────────────┘
       Request C ─────► Complete

Best example

Operations where order matters

exhaustMap()

First/current wins while busy

A ─────► Request A ─────────────► Complete
          │
B ────────X ignored
          │
C ────────X ignored

After A completes, a later value can start another operation.

Best example

Prevent repeated submit/login requests

7. Easy Way to Remember All Four

Imagine an elevator.

switchMap()

New request arrives?

Forget previous destination,
go to latest.

Latest wins.


mergeMap()

Imagine multiple elevators:

A → Elevator 1
B → Elevator 2
C → Elevator 3

Everyone can proceed concurrently.

All run.


concatMap()

One elevator and a queue:

A → Finish
     ↓
B → Finish
     ↓
C → Finish

Wait your turn.


exhaustMap()

Elevator is occupied:

A → Elevator

B → ignored
C → ignored

After A finishes, a later request can be accepted.

Busy? Ignore.


8. switchMap() vs mergeMap()

This is a very common interview question.

The fundamental difference is:

switchMap() switches to the latest inner Observable, while mergeMap() keeps multiple inner Observables active concurrently.

Suppose:

Search A
Search B
Search C

With switchMap()

A → Request A
       X

B → Request B
       X

C → Request C → Result

Only the latest active request matters.


With mergeMap()

A → Request A ─────► Result A

B → Request B ─► Result B

C → Request C ───────► Result C

All requests can complete.

Also, because they are concurrent, their completion order is not guaranteed to match their starting order.


9. Why switchMap() Is Better for Search

Suppose the user searches:

L
La
Lap
Laptop

Using mergeMap():

L      → Request 1
La     → Request 2
Lap    → Request 3
Laptop → Request 4

All may remain active.

Worse, responses could arrive out of order:

Request Laptop → returns first
Request Lap    → returns second
Request L      → returns last

If you simply update the UI with every response, an older result could overwrite a newer one.

With switchMap():

L      → Request 1 ─X

La     → Request 2 ─X

Lap    → Request 3 ─X

Laptop → Request 4 ─────► Latest Result

That's why switchMap() is typically preferred for autocomplete/search.


10. Why mergeMap() Can Be Better for Save Operations

Suppose the user performs independent save operations:

Save Product A
Save Product B
Save Product C

You don't necessarily want:

A → cancelled because B arrived
B → cancelled because C arrived

That could happen conceptually with switchMap()'s switching behavior.

If every save must be processed and concurrency is acceptable:

mergeMap(product =>
  this.productService.save(product)
)

is often more appropriate.

Product A → Save ─────► Complete

Product B → Save ─────► Complete

Product C → Save ─────► Complete

11. switchMap() vs mergeMap() vs concatMap() vs exhaustMap()

This table is worth remembering for interviews.

Operator Previous active work New value Execution Typical use
switchMap() Unsubscribed Accepted Latest Search
mergeMap() Continues Accepted Concurrent Independent operations
concatMap() Continues Queued Sequential Ordered operations
exhaustMap() Continues Ignored while busy One active Prevent repeated submit

One-line memory trick

switchMap → CANCEL previous
mergeMap  → RUN all
concatMap → QUEUE
exhaustMap→ IGNORE new

12. Very Important: These Operators Return Observables

Consider:

switchMap(search =>
  this.productService.searchProducts(search)
)

The callback must produce an Observable-like inner source.

For example:

searchProducts(search)

returns:

Observable<Product[]>

Conceptually:

Search text
    │
    ▼
switchMap()
    │
    ▼
HTTP Observable
    │
    ▼
Product[]

That's why these are called higher-order mapping operators: they map values to inner Observables and then flatten those Observables according to a concurrency strategy.


13. Where Does catchError() Fit?

You can combine these operators.

For example:

this.searchControl.valueChanges
  .pipe(

    debounceTime(300),

    distinctUntilChanged(),

    switchMap(search =>
      this.productService
        .searchProducts(search)
        .pipe(

          catchError(error => {

            console.error(error);

            return of([]);

          })

        )
    )

  )
  .subscribe(products => {

    this.products = products;

  });

Flow:

Search
  │
  ▼
debounceTime()
  │
  ▼
distinctUntilChanged()
  │
  ▼
switchMap()
  │
  ▼
HTTP
  │
  ├──── Success ────► Products
  │
  └──── Error
          │
          ▼
     catchError()
          │
          ▼
          []

An important advanced detail is that where you place catchError() matters. Catching inside the switchMap() lets the outer search stream continue listening for later search terms after one HTTP failure.


Key Points

  • catchError() handles errors and can return a fallback/replacement Observable.
  • switchMap() switches to the latest inner Observable and unsubscribes from the previous one.
  • mergeMap() allows multiple inner Observables to run concurrently.
  • concatMap() queues inner Observables and runs them sequentially.
  • exhaustMap() ignores new source values while the current inner Observable is active.
  • switchMap() is commonly used for search/autocomplete.
  • mergeMap() is useful when all independent operations should run and concurrency is acceptable.
  • concatMap() is useful when every operation must run in order.
  • exhaustMap() is useful for preventing repeated submit/login actions while one request is active.
  • mergeMap() does not preserve completion order when concurrent work finishes at different times.
  • switchMap() does not mean "cancel everything"; it unsubscribes from the previous inner Observable when a new source emission arrives.

The most important memory rule is:

switchMap  → Latest wins
mergeMap   → All run
concatMap  → One-by-one
exhaustMap → First/current wins while busy
catchError → Handle failure

Interview Questions and Answers

1. What does catchError() do?

Answer:
catchError() catches an error from an Observable and lets us recover by returning another Observable, providing a fallback value, or rethrowing the error.

catchError(error => {
  console.error(error);
  return of([]);
})

2. What does switchMap() do?

Answer:
switchMap() maps each source value to an inner Observable. When a new source value arrives, it unsubscribes from the previous inner Observable and switches to the latest one.

Remember: Latest wins.


3. What does mergeMap() do?

Answer:
mergeMap() maps source values to inner Observables and allows multiple inner Observables to execute concurrently.

Remember: All run.


4. What does concatMap() do?

Answer:
concatMap() maps values to inner Observables and executes them sequentially. Each one waits for the previous one to complete.

Remember: One-by-one.


5. What does exhaustMap() do?

Answer:
exhaustMap() starts an inner Observable and ignores new source values until that Observable completes.

Remember: Busy? Ignore new values.


6. What is the difference between switchMap() and mergeMap()?

Answer:
switchMap() switches to the newest inner Observable and unsubscribes from the previous one. mergeMap() keeps multiple inner Observables active concurrently.

switchMap
A ─X
B ─X
C ─────► Result


mergeMap
A ─────► Result
B ─────► Result
C ─────► Result

7. Which operator should be used for autocomplete?

Answer:
switchMap() is commonly used because only the latest search request matters.


8. Which operator should be used when all requests must run concurrently?

Answer:
mergeMap() is appropriate when all operations matter and concurrent execution is acceptable.


9. Which operator should be used when request order matters?

Answer:
concatMap() because it queues the operations and executes them sequentially.


10. Which operator can prevent repeated form submissions?

Answer:
exhaustMap() is often suitable because it ignores additional submit events while the current request is still active.


11. What is the easiest way to remember the four mapping operators?

switchMap  → Latest wins
mergeMap   → All run
concatMap  → Queue
exhaustMap → Ignore while busy

12. Short Interview Answer

switchMap keeps the latest inner Observable, mergeMap runs inner Observables concurrently, concatMap queues and runs them sequentially, exhaustMap ignores new emissions while the current inner Observable is active, and catchError handles errors by recovering, replacing, or rethrowing the failed stream.

← Back to Articles