RxJS Error Handling: throwError, catchError, retry, retryWhen and finalize

Angular Tutorial: Beginner to Advanced · 10. HTTP and RxJS · 6 min read · Updated

Requests fail, servers time out, users lose connection. An Observable that errors ends, so you need a plan: recover with a fallback, try again, or report the problem clearly.

What you will learn

  • throwError and catchError
  • retry, with a delay and backoff, and why retryWhen is deprecated
  • finalize and unsubscribing correctly

throwError and catchError

throwError(() => error) creates an Observable that fails immediately. catchError intercepts an error and decides what to do next. Its function must return an Observable:

// src/app/rxjs5/errors.ts
import { EMPTY, Observable, catchError, finalize, of, retry, throwError, timer } from 'rxjs';

// 1. Replace the error with a fallback value
export const withFallback = (source$: Observable<string>) =>
  source$.pipe(catchError(() => of('fallback value')));

// 2. Add context and rethrow so the caller still sees a failure
export const withContext = (source$: Observable<string>) =>
  source$.pipe(
    catchError((error: unknown) =>
      throwError(() => new Error('Could not load the profile: ' + (error as Error).message)),
    ),
  );

// 3. Swallow the error and complete (nothing is emitted)
export const ignoreErrors = (source$: Observable<string>) => source$.pipe(catchError(() => EMPTY));

// 4. Retry a few times before giving up
export const retryThrice = (source$: Observable<string>) => source$.pipe(retry(3));

// 5. Retry with a growing delay: 1s, 2s, 4s (exponential backoff)
export const retryWithBackoff = (source$: Observable<string>) =>
  source$.pipe(
    retry({
      count: 3,
      delay: (_error, attempt) => timer(2 ** (attempt - 1) * 1000),
    }),
  );

// 6. Run cleanup when the stream ends: success, error or unsubscribe
export function withCleanup(source$: Observable<string>, onDone: () => void) {
  return source$.pipe(finalize(onDone));
}

Rules of thumb:

  • Where you put catchError matters. Put it inside an inner Observable (for example inside switchMap) so one failing request does not kill the whole outer stream, such as a search box that must keep working.
  • If you do not handle an error, subscribe without an error callback logs it as an unhandled error.
  • Convert technical errors into user-friendly messages. Do not show raw server text.
  • Retry only safe operations. Retrying a GET is fine. Retrying a POST can create duplicates unless the server makes it idempotent.
  • Do not retry errors that will never succeed, such as a 400 or 404. Filter by status before retrying.

retry and retryWhen

retry(3) re-subscribes up to three times when the source errors. The newer `retry({ count, delay })` configuration takes a delay, which can be a number of milliseconds or a function returning an Observable, as in the backoff example above.

The older `retryWhen` did the same with more code. It is deprecated and will be removed in a future RxJS major version, so write retry({ delay }) instead:

// Old (still works today, but deprecated)
source$.pipe(retryWhen((errors) => errors.pipe(delay(1000), take(3))));

// New
source$.pipe(retry({ count: 3, delay: 1000 }));

A smart retry in an Angular service:

// src/app/rxjs5/user-service.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, retry, throwError, timer } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);

  load(id: number): Observable<unknown> {
    return this.http.get(`https://api.example.com/users/${id}`).pipe(
      retry({
        count: 2,
        // Only retry network problems and server errors, never 4xx
        delay: (error: HttpErrorResponse, attempt) =>
          error.status === 0 || error.status >= 500 ? timer(attempt * 500) : throwError(() => error),
      }),
      catchError((error: HttpErrorResponse) =>
        throwError(() => new Error(error.status === 404 ? 'User not found' : 'Please try again later')),
      ),
    );
  }
}

Unsubscribing from Observables

A subscription that never ends keeps running and keeps memory alive, even after the component is gone. Streams that complete on their own (HTTP calls, of, take(1)) do not need manual cleanup. Streams that never complete (interval, fromEvent, form valueChanges, subjects, store streams) do.

Your options, from best to worst:

  1. Do not subscribe by hand. Use the async pipe or toSignal().
  2. `takeUntilDestroyed()` in components and services.
  3. `take(1)` or `first()` when you only need one value.
  4. `takeUntil(destroy$)`, the classic pattern.
  5. Manual `unsubscribe()` in ngOnDestroy.
// src/app/rxjs5/clean-component.ts
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, Subscription, interval, takeUntil } from 'rxjs';

@Component({ selector: 'app-clean', template: `<p>Open the console</p>` })
export class CleanComponent implements OnInit, OnDestroy {
  // Option 2: automatic
  constructor() {
    interval(1000).pipe(takeUntilDestroyed()).subscribe((n) => console.log('auto', n));
  }

  // Option 4: classic notifier
  private readonly destroy$ = new Subject<void>();

  // Option 5: manual
  private readonly subscription = new Subscription();

  ngOnInit(): void {
    interval(1000).pipe(takeUntil(this.destroy$)).subscribe((n) => console.log('notifier', n));
    this.subscription.add(interval(1000).subscribe((n) => console.log('manual', n)));
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
    this.subscription.unsubscribe(); // one call closes everything added to it
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { CleanComponent } from './rxjs5/clean-component';

@Component({
  imports: [CleanComponent],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <app-clean />
</main>

Test the behaviour

// src/app/rxjs5/errors.spec.ts
import { EMPTY, Observable, Subject, lastValueFrom, of, throwError, toArray } from 'rxjs';
import { ignoreErrors, retryThrice, retryWithBackoff, withCleanup, withContext, withFallback } from './errors';

// A source that fails the first N times, then succeeds
function flaky(failures: number): { source$: Observable<string>; attempts: () => number } {
  let attempts = 0;
  const source$ = new Observable<string>((subscriber) => {
    attempts++;
    if (attempts <= failures) {
      subscriber.error(new Error('boom ' + attempts));
    } else {
      subscriber.next('ok after ' + attempts);
      subscriber.complete();
    }
  });
  return { source$, attempts: () => attempts };
}

describe('error handling operators', () => {
  afterEach(() => vi.useRealTimers());

  it('catchError can return a fallback value', async () => {
    const result = await lastValueFrom(withFallback(throwError(() => new Error('x'))));
    expect(result).toBe('fallback value');
  });

  it('catchError can add context and rethrow', async () => {
    await expect(lastValueFrom(withContext(throwError(() => new Error('timeout'))))).rejects.toThrow(
      'Could not load the profile: timeout',
    );
  });

  it('catchError with EMPTY completes without values', async () => {
    const values = await lastValueFrom(ignoreErrors(throwError(() => new Error('x'))).pipe(toArray()));
    expect(values).toEqual([]);
  });

  it('retry(3) tries the source again', async () => {
    const { source$, attempts } = flaky(2);
    expect(await lastValueFrom(retryThrice(source$))).toBe('ok after 3');
    expect(attempts()).toBe(3);
  });

  it('retry(3) gives up after three retries', async () => {
    const { source$, attempts } = flaky(10);
    await expect(lastValueFrom(retryThrice(source$))).rejects.toThrow('boom 4');
    expect(attempts()).toBe(4); // first try + 3 retries
  });

  it('retry with backoff waits longer each time', async () => {
    vi.useFakeTimers();
    const { source$, attempts } = flaky(2);
    let result = '';
    retryWithBackoff(source$).subscribe((v) => (result = v));
    expect(attempts()).toBe(1);
    vi.advanceTimersByTime(1000); // first delay: 1s
    expect(attempts()).toBe(2);
    vi.advanceTimersByTime(1999); // second delay: 2s, not yet
    expect(attempts()).toBe(2);
    vi.advanceTimersByTime(1);
    expect(attempts()).toBe(3);
    expect(result).toBe('ok after 3');
  });

  it('finalize runs on success, error and unsubscribe', async () => {
    const calls: string[] = [];
    await lastValueFrom(withCleanup(of('a'), () => calls.push('success')));
    await lastValueFrom(withCleanup(throwError(() => new Error('x')), () => calls.push('error'))).catch(() => {});
    const sub = withCleanup(new Subject<string>(), () => calls.push('unsubscribed')).subscribe();
    sub.unsubscribe();
    expect(calls).toEqual(['success', 'error', 'unsubscribed']);
    expect(EMPTY).toBeDefined();
  });
});

Try it yourself

  1. Add catchError inside a switchMap for a search, and show that typing again still works after a failed request.
  2. Retry only when the HTTP status is 503.
  3. Convert a retryWhen example from an old tutorial to retry({ delay }).

Quick check

  1. What must the function passed to catchError return?
  2. Why is retryWhen no longer recommended?
  3. Which streams need manual unsubscribing?

Next, share values between parts of the app with Subjects.

Frequently asked questions

Should every subscribe have an error callback?

If the stream can fail and nobody else handles it, yes. Otherwise the error is reported as unhandled. A catchError earlier in the pipe also counts as handling.

What is the difference between catchError and the subscribe error callback?

catchError can recover and keep the stream going with another Observable. The subscribe error callback only observes the failure, and the stream is already finished.