Skip to content
Angular ng rxjs 4 min read

RxJS Error Handling

An Observable signals failure by calling its error channel, which immediately terminates the stream — no further values, no complete. Because errors are part of the contract rather than thrown exceptions you catch with try/catch, RxJS gives you dedicated operators to inspect, recover from, or retry a failed pipeline. Handling errors well is what separates a fragile feature that blanks out on a flaky network from a resilient one that retries, falls back, and tells the user what happened.

How errors flow through a stream

Every subscribe() accepts an observer with three callbacks: next, error, and complete. An Observable emits zero or more next values and then ends with exactly one of error or complete — never both. Once error fires, the stream is dead and the subscription is torn down automatically.

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

of(1, 2, 3)
  .pipe(concatMap((n) => (n === 2 ? throwError(() => new Error('boom')) : of(n))))
  .subscribe({
    next: (value) => console.log('next:', value),
    error: (err) => console.log('error:', err.message),
    complete: () => console.log('complete'),
  });

Output:

next: 1
error: boom

Notice complete never runs and 3 never emits — the error short-circuits everything downstream.

catchError

catchError intercepts an error and lets you return a replacement Observable. Whatever you return becomes the continuation of the stream, so you can swap a failed HTTP call for a safe default, an empty list, or a rethrown error after logging.

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

interface User {
  id: number;
  name: string;
}

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

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users').pipe(
      catchError((error) => {
        console.error('Failed to load users', error);
        return of([]); // graceful fallback keeps the stream alive
      }),
    );
  }
}

The key choice is what you return from catchError:

Return valueEffect on the stream
of(fallback)Emits the fallback, then completes normally
EMPTYCompletes immediately with no value
throwError(() => err)Re-raises (after you log or transform it)
Another HTTP callRetries against a backup endpoint

Re-throw, do not swallow, errors you cannot meaningfully recover from. Returning of(null) everywhere hides bugs and produces confusing empty UI. Log, optionally transform, and throwError(() => err) so a global handler or the caller can react.

retry

Transient failures — a dropped connection, a 503 during a deploy — often succeed on a second attempt. retry resubscribes to the source up to count times when it errors, and only forwards the error if every attempt fails.

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

const http = inject(HttpClient);

http.get<unknown>('/api/report').pipe(
  retry({ count: 3, delay: 1000 }), // 3 retries, 1s apart
  catchError(() => of(null)),
);

The modern object form retry({ count, delay }) adds a fixed delay between attempts. Provide a function instead of a number to compute the delay dynamically — the basis for exponential backoff. Always place catchError after retry so it only runs once the retries are exhausted.

retryWhen and backoff

retryWhen (deprecated in favour of retry’s delay callback) gave full control over when to retry by exposing the stream of errors. In RxJS 7.4+ the same power lives in the delay notifier, which receives the error and the 1-based attempt number and returns an Observable; emitting resubscribes, erroring or completing gives up.

import { timer, throwError } from 'rxjs';
import { retry } from 'rxjs/operators';

source$.pipe(
  retry({
    count: 4,
    delay: (error, retryCount) => {
      if (error.status === 400) {
        return throwError(() => error); // never retry client errors
      }
      const backoff = Math.pow(2, retryCount) * 1000; // 2s, 4s, 8s, 16s
      console.log(`Retry ${retryCount} in ${backoff}ms`);
      return timer(backoff);
    },
  }),
);

Output:

Retry 1 in 2000ms
Retry 2 in 4000ms
Retry 3 in 8000ms

This pattern retries only server-side and network errors, backs off exponentially to avoid hammering a struggling backend, and bails out instantly on a 400 because resending a bad request will never help.

Centralizing errors with a functional interceptor

Rather than wrapping every call, register one functional interceptor (Angular 15+) that applies a consistent retry-and-report policy across all HTTP traffic.

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { retry, catchError, throwError } from 'rxjs';
import { Router } from '@angular/router';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);
  return next(req).pipe(
    retry({ count: 2, delay: 800 }),
    catchError((error) => {
      if (error.status === 401) {
        router.navigate(['/login']);
      }
      return throwError(() => error);
    }),
  );
};

Wire it up with provideHttpClient(withInterceptors([errorInterceptor])) in app.config.ts.

Best practices

  • Put catchError after retry so recovery runs only once all retry attempts are spent.
  • Use retry({ count, delay }) with exponential backoff for transient/server errors, and never retry 4xx client errors that will deterministically fail again.
  • Return a meaningful continuation from catchErrorof(fallback) to recover, EMPTY to silently complete, or throwError(() => err) to re-raise after logging.
  • Inside flattening operators, scope catchError to the inner Observable when you want one item’s failure to not kill the whole outer stream.
  • Centralize cross-cutting policy (auth redirects, retries, toasts) in a functional HttpInterceptorFn instead of repeating logic per request.
  • Keep error callbacks side-effect-light; do logging in tap({ error }) so the recovery operator stays focused on producing a value.
Last updated June 14, 2026
Was this helpful?