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
throwErrorandcatchErrorretry, with a delay and backoff, and whyretryWhenis deprecatedfinalizeand 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
catchErrormatters. Put it inside an inner Observable (for example insideswitchMap) 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,
subscribewithout anerrorcallback 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:
- Do not subscribe by hand. Use the
asyncpipe ortoSignal(). - `takeUntilDestroyed()` in components and services.
- `take(1)` or `first()` when you only need one value.
- `takeUntil(destroy$)`, the classic pattern.
- 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
- Add
catchErrorinside aswitchMapfor a search, and show that typing again still works after a failed request. - Retry only when the HTTP status is 503.
- Convert a
retryWhenexample from an old tutorial toretry({ delay }).
Quick check
- What must the function passed to
catchErrorreturn? - Why is
retryWhenno longer recommended? - Which streams need manual unsubscribing?
Next, share values between parts of the app with Subjects.