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 value | Effect on the stream |
|---|---|
of(fallback) | Emits the fallback, then completes normally |
EMPTY | Completes immediately with no value |
throwError(() => err) | Re-raises (after you log or transform it) |
| Another HTTP call | Retries 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, andthrowError(() => 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
catchErrorafterretryso recovery runs only once all retry attempts are spent. - Use
retry({ count, delay })with exponential backoff for transient/server errors, and never retry4xxclient errors that will deterministically fail again. - Return a meaningful continuation from
catchError—of(fallback)to recover,EMPTYto silently complete, orthrowError(() => err)to re-raise after logging. - Inside flattening operators, scope
catchErrorto 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
HttpInterceptorFninstead 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.