Angular
·7 min read·↗

How to Handle and Catch Errors in RxJS and Angular

Main cover illustration for article: How to Handle and Catch Errors in RxJS and Angular
Summarize with AI:

When working with RxJS observables in Angular, error handling is one of the first stumbling blocks for developers. You might naturally reach for a standard JavaScript try-catch block, only to discover that it fails completely to catch asynchronous errors emitted by an HTTP request or data stream.

⚡ Using Modern Angular (v17+)? Jump directly to Modern Angular: Signals, inject(), and takeUntilDestroyed to see how to handle errors cleanly without manual subscriptions or memory leaks.

In this guide, we will explore why try-catch does not work with asynchronous streams, how to use core RxJS operators (catchError, throwError, EMPTY, retry), and how to handle errors reactively in modern Angular using Signals and toSignal.

Let's start by understanding the problem with a standard API service.


1. The Scenario: An Unhandled API Error 🍺

Imagine an Angular service that fetches a list of products or beers from an API:

// Legacy Angular service (v2 - v16)
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
 
@Injectable({ providedIn: 'root' })
export class BeerService {
  private apiUrl = 'https://api.example.com/beers';
  constructor(private http: HttpClient) {}
 
  getBeers(): Observable<any[]> {
    return this.http.get<any[]>(this.apiUrl);
  }
}

Now, consider a component that attempts to guard the call with a standard try-catch:

export class AppComponent implements OnInit {
  beers: any[] = [];
  errorMessage = '';
 
  constructor(private beerService: BeerService) {}
 
  ngOnInit() {
    try {
      this.beerService.getBeers().subscribe((data) => {
        this.beers = data;
      });
    } catch (err) {
      // ❌ THIS WILL NEVER EXECUTE when an HTTP request fails!
      this.errorMessage = 'An error occurred';
    }
  }
}

Why try-catch Fails with Observables

A try-catch block is synchronous. It executes when setting up the subscription and exits immediately. When the HTTP request fails seconds later, the execution occurs inside the asynchronous callback queue, completely outside the original try-catch scope.

Now let's see where the error actually lands in a subscription.


2. Catching Errors in the Subscriber Object 📬

When you call .subscribe(), the subscriber object accepts three callbacks: next, error, and complete:

this.beerService.getBeers().subscribe({
  next: (data) => {
    this.beers = data;
  },
  error: (err) => {
    console.error('HTTP Request failed:', err);
    this.errorMessage = 'Could not load items from the server.';
  },
  complete: () => {
    console.log('Stream completed successfully.');
  }
});

While handling errors inside error: works for simple cases, it breaks stream composition when chaining operators.

Let's look at how to handle errors inside the stream pipeline using RxJS operators.


3. RxJS Operators for Error Handling 🛠️

RxJS provides dedicated operators designed to intercept errors before they break your application logic.

1. catchError: Providing Fallback Values

catchError intercepts an error in the stream and allows you to return a fallback observable (using of()) so the component receives a valid default state instead of crashing:

import { catchError, of } from 'rxjs';
 
this.beerService.getBeers().pipe(
  catchError((error) => {
    console.warn('API error caught, providing cached fallback:', error);
    return of([{ id: 0, name: 'Default Offline Beer' }]);
  })
).subscribe((beers) => {
  this.beers = beers; // Receives the fallback array cleanly!
});

2. throwError: Transforming and Re-throwing

If you want to log or transform the error into a user-friendly domain error and let the subscriber handle it:

import { catchError, throwError } from 'rxjs';
 
this.beerService.getBeers().pipe(
  catchError((error) => {
    const customMessage = error.status === 404 
      ? 'The requested beer catalog was not found.' 
      : 'Internal server error. Please try again later.';
    return throwError(() => new Error(customMessage));
  })
).subscribe({
  next: (data) => this.beers = data,
  error: (err) => this.errorMessage = err.message
});

3. EMPTY: Silently Completing the Stream

If an error occurs and you want to prevent downstream notifications without triggering an error state, return EMPTY:

import { catchError, EMPTY } from 'rxjs';
 
this.beerService.getBeers().pipe(
  catchError((error) => {
    console.error('Silent failure:', error);
    return EMPTY; // Stream completes immediately with 0 emissions
  })
).subscribe((beers) => {
  this.beers = beers;
});

4. retry: Resubscribing on Transient Failures

For temporary network glitches, add retry before catchError:

import { retry, catchError, of } from 'rxjs';
 
this.beerService.getBeers().pipe(
  retry({ count: 2, delay: 1000 }), // Tries up to 2 times with a 1s delay
  catchError((error) => of([]))
).subscribe(data => this.beers = data);

Now let's see how modern Angular (v17+) simplifies all of this with Signals and toSignal.


4. Modern Angular: Signals, inject(), and Clean Error Handling ⚡

In modern Angular (v17+), we avoid manual .subscribe() calls in components. Instead, we use Standalone Components, the inject() function, and the toSignal() interoperability helper.

Modern Service Pattern

// beer.service.ts (Modern Standalone)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, of } from 'rxjs';
 
export interface Beer {
  id: number;
  name: string;
}
 
@Injectable({ providedIn: 'root' })
export class BeerService {
  private readonly http = inject(HttpClient);
  private readonly apiUrl = 'https://api.example.com/beers';
 
  getBeers() {
    return this.http.get<Beer[]>(this.apiUrl).pipe(
      catchError((error) => {
        console.error('BeerService failed:', error);
        return of([] as Beer[]); // Safe empty fallback
      })
    );
  }
}

Modern Component Pattern (No Manual Subscriptions)

// app.component.ts (Modern Angular v17+)
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { BeerService, Beer } from './beer.service';
 
@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <main class="p-6 max-w-2xl mx-auto">
      <h1 class="text-2xl font-bold mb-4">Beer Catalog</h1>
 
      @if (beers().length > 0) {
        <ul class="space-y-2">
          @for (beer of beers(); track beer.id) {
            <li class="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-lg">
              {{ beer.name }}
            </li>
          }
        </ul>
      } @else {
        <p class="text-neutral-500">No beers available or server is offline.</p>
      }
    </main>
  `
})
export class AppComponent {
  private readonly beerService = inject(BeerService);
 
  // Automatically converted to a reactive Signal with safe initial value!
  readonly beers = toSignal(this.beerService.getBeers(), { initialValue: [] as Beer[] });
}

Why the Modern Pattern is Superior:

  1. Zero Memory Leaks: toSignal() automatically unsubscribes when the component is destroyed.
  2. No Boilerplate: No ngOnInit, no ngOnDestroy, and no manual .subscribe() logic.
  3. Template Reactivity: Works directly with modern @if and @for control flow.

Comparison Matrix: RxJS Error Strategies 📊

Operator / ApproachEmits to Subscriber?Completes Stream?Best Use Case
catchError + of(fallback)✅ Yes (next)✅ YesProviding safe default or cached data
catchError + throwError❌ No (error)❌ TerminatedLogging, customizing, and surfacing errors
catchError + EMPTY❌ No✅ YesSilent failures where nothing should render
retry({ count, delay })🔄 RetriesOnly if all attempts failTransient network and server timeouts
toSignal() + catchError✅ Signal StateHandled nativelyModern Angular Signal-based state

Recap 🛠️

Handling errors in RxJS and Angular is straightforward when you use the right tools:

  1. Never use synchronous try-catch for asynchronous observables.
  2. Use catchError with of() to supply fallback data and keep your application resilient.
  3. Use retry() before catchError for automatic recovery from transient HTTP failures.
  4. In modern Angular, combine inject(), catchError, and toSignal() for declarative, memory-safe data fetching.

For more modern Angular architectural patterns, check out my articles on How to Use ng-template, ng-container, and ng-content and How to Use Route Parameters in Angular with Signals!

Part of the Angular Series

These are my experiences learning and facing my daily challenges working with Angular.

View Entire Series

Frequently Asked Questions

Why doesn't try-catch work for handling errors in RxJS observables?

A try-catch block is synchronous and only catches errors thrown immediately in the current call stack. RxJS observables emit values asynchronously over time, so errors that occur in a stream do not bubble up to a surrounding try-catch. Instead, you need to use RxJS error-handling operators like catchError inside the pipe.

What is the difference between catchError, throwError, and EMPTY in RxJS?

catchError intercepts an error in the stream and returns a replacement observable (such as a fallback value with of()). throwError re-throws or transforms the error down the stream to be handled by downstream subscribers. EMPTY immediately completes the stream without emitting any next values or error notifications.

How do I handle HTTP errors reactively in Modern Angular with Signals?

In modern Angular, you can use the toSignal() helper from @angular/core/rxjs-interop combined with catchError in your service pipe. This automatically transforms your asynchronous stream into a reactive Signal with a fallback default state, eliminating manual subscribe calls.

How can I automatically retry a failed HTTP request before catching the error?

You can place the retry(count) operator before catchError in your observable pipe. RxJS will resubscribe to the source observable up to the specified number of times before finally passing the error to catchError.

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.