---
title: "How to Handle and Catch Errors in RxJS and Angular"
date: "2022-03-11"
slug: "how-to-handle-and-catch-errors-in-rxjs"
author: "Dany Paredes"
canonical: "https://danywalls.com/how-to-handle-and-catch-errors-in-rxjs"
description: "Learn how to catch, handle, and recover from errors in RxJS streams and Angular using catchError, throwError, EMPTY, and modern Angular Signals interop."
---


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`](#modern-angular-signals-inject-and-clean-error-handling) 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:

```typescript
// 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`:

```typescript
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`:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
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`:

```typescript
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

```typescript
// 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)

```typescript
// 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 / Approach | Emits to Subscriber? | Completes Stream? | Best Use Case |
| :--- | :---: | :---: | :--- |
| **`catchError` + `of(fallback)`** | ✅ Yes (`next`) | ✅ Yes | Providing safe default or cached data |
| **`catchError` + `throwError`** | ❌ No (`error`) | ❌ Terminated | Logging, customizing, and surfacing errors |
| **`catchError` + `EMPTY`** | ❌ No | ✅ Yes | Silent failures where nothing should render |
| **`retry({ count, delay })`** | 🔄 Retries | Only if all attempts fail | Transient network and server timeouts |
| **`toSignal()` + `catchError`** | ✅ Signal State | Handled natively | Modern 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](/how-to-get-and-use-ng-template-ng-container-and-ng-content) and [How to Use Route Parameters in Angular with Signals](/learn-route-parameters-in-angular-with-example)!

