| |

Angular 44 🅰️ Error Handling in RxJS

An Observable has three channels: next for values, error for a terminal failure, and complete for a terminal success. The error channel is the one that is most often mishandled. Unlike next, which can fire any number of times, error fires once and terminates the stream — after it fires, no further values are emitted and the subscription is closed. This makes error handling in RxJS fundamentally different from error handling in promises or try/catch. A single unhandled error in an inner Observable can tear down an entire pipeline, silently canceling every subscription downstream. A misplaced catchError can swallow a failure that should have propagated, or can catch an error from a different source than intended. This chapter covers the error channel, the catchError and retry operators, the difference between recovering and rethrowing, the placement of catchError in a pipeline, the interaction with switchMap and its siblings, and the patterns that make RxJS error handling reliable in Angular applications.

Key point: catchError intercepts the error notification and returns a new Observable. If it returns an Observable that emits a value, the error is recovered and the stream continues with that value. If it returns throwError(() => error), the error is rethrown and continues to propagate. The operator must be placed after the source that can error — a catchError before a switchMap does not catch the inner Observable’s errors. retry resubscribes to the source on error, and it is the tool for transient failures. Errors are terminal: once an Observable errors, it is done, and the only way to continue is to return a new Observable from catchError or to resubscribe with retry.


The error channel

An Observable can emit any number of values through next, and then either complete or error. Once error fires, the Observable is finished — no more values, no completion notification, and the subscriber’s error callback is invoked.

import { Observable } from 'rxjs';

const source$ = new Observable<number>((subscriber) => {
  subscriber.next(1);
  subscriber.next(2);
  subscriber.error(new Error('failed'));
  subscriber.next(3); // never delivered
});

source$.subscribe({
  next: (v) => console.log('next:', v),
  error: (e) => console.log('error:', e.message),
  complete: () => console.log('complete'),
});
// next: 1
// next: 2
// error: failed

The third next is never delivered because the error terminated the stream. The complete callback never fires because the stream errored instead of completing.

Why the error is terminal. A stream that has errored has no defined state — the producer has failed, and continuing to read from it would be reading from a broken source. Terminating is the safe behavior. The consumer that wants to continue must handle the error and start a new stream.

Why the error callback is required. If a subscription’s error callback is not provided, the error is rethrown asynchronously, which in a browser means it becomes an unhandled error. In Angular, this reaches the ErrorHandler and is logged, but the stream is dead. Providing the error callback is the minimum for handling.

Why the error object should carry information. A HttpErrorResponse carries the status and the body. A custom error should carry whatever the handler needs to decide what to do. An error that is just a string loses the structure that makes handling possible.

Why the error channel is different from try/catch. A try/catch surrounds a block of synchronous code. An Observable error is asynchronous and can come from any point in the pipeline. The catchError operator is the asynchronous equivalent, and it operates on a stream rather than a block.

Why a single unhandled error kills the pipeline. A pipeline of operators is a chain of subscriptions. When an inner Observable errors, the error propagates upstream through each operator, and each operator’s error handling is invoked. If no operator handles it, it reaches the subscriber, which terminates the whole chain. This is why a catchError in the right place matters — without it, one failure in one inner stream can tear down the entire view model.


catchError

catchError is the operator that intercepts the error notification and returns a new Observable. The returned Observable replaces the errored one, and the stream continues from that point.

import { catchError, of } from 'rxjs';

source$.pipe(
  catchError((error) => {
    console.error(error);
    return of(0); // recover with a default value
  }),
).subscribe((v) => console.log(v));

The callback receives the error and returns an Observable. of(0) emits 0 and completes, so the subscriber sees 0 and the stream completes normally. The error is recovered — the subscriber never sees the error notification.

Why the return type must be an Observable. The callback must return an Observable, not a value. of(value) wraps a value in an Observable. throwError(() => error) returns an Observable that emits the error. Returning a value directly is a type error.

Recovering with a fallback. The most common recovery is to return a default value.

this.http.get<User[]>('/api/users').pipe(
  catchError(() => of([])),
);

The subscriber receives an empty array instead of the error. This is the correct pattern when the view can render with an empty result — a list with no items, a search with no matches.

Rethrowing. The error can be rethrown after a side effect.

this.http.get<User[]>('/api/users').pipe(
  catchError((error: HttpErrorResponse) => {
    this.logger.error(error);
    return throwError(() => error);
  }),
);

The error is logged and rethrown, so the subscriber’s error callback fires. This is the pattern when the interceptor or a higher-level handler needs to see the error — for example, a component that shows a specific message based on the status.

Why throwError takes a function. The throwError function takes a factory that produces the error, not the error itself. This is because the Observable is created lazily, and the factory is called when the Observable is subscribed. The function form is the modern signature; the direct form is deprecated.

Why the error can be transformed. The callback can return a different error than the one it received.

this.http.get('/api/data').pipe(
  catchError((error: HttpErrorResponse) => {
    if (error.status === 404) {
      return of(null);
    }
    return throwError(() => new Error('Could not load data'));
  }),
);

A 404 becomes null (the resource is absent, which is a valid state), and everything else becomes a generic error. The transformation is where the domain logic of error handling lives.

Why EMPTY is sometimes the right recovery. EMPTY is an Observable that completes immediately without emitting. Returning it from catchError recovers the error by completing the stream with no value.

source$.pipe(
  catchError(() => EMPTY),
);

The subscriber’s complete callback fires, and no value is delivered. This is the pattern when the stream should silently end on error — for example, a subscription to a WebSocket that should close quietly when the connection drops.


retry and retryWhen

retry resubscribes to the source when it errors. The resubscription starts the source from the beginning, and the operator retries a configured number of times before giving up.

this.http.get('/api/data').pipe(
  retry(3),
  catchError((error) => throwError(() => error)),
);

The request is attempted up to four times (the initial attempt plus three retries). If all fail, the error propagates to catchError. The retries are immediate by default.

Retry with delay. The retry can wait between attempts, which is important for transient failures.

this.http.get('/api/data').pipe(
  retry({ count: 3, delay: 1000 }),
);

The first retry waits one second, the second waits one second, and so on. The delay gives the server time to recover.

Exponential backoff. The delay can be a function of the retry attempt, which produces an exponential backoff.

this.http.get('/api/data').pipe(
  retry({ count: 3, delay: (attempt) => Math.pow(2, attempt) * 1000 }),
);

The first retry waits two seconds, the second four, the third eight. Exponential backoff reduces the load on a struggling server and is the standard pattern for transient failures.

Why retry should be limited. An unlimited retry on a failing server is a way to make the failure worse — every client retries forever, and the server is overloaded with retries. The count is a design decision, and the delay is what makes the retry pattern healthy.

Why retry is for idempotent operations. A GET can be retried safely because it does not change the server state. A POST that creates a resource cannot be retried without the risk of creating two resources. The retry should be applied to operations that are safe to repeat, and the pattern is usually implemented in an interceptor with a check on the method.

retryWhen for custom logic. retryWhen takes a function that receives a stream of errors and returns a stream that controls when to retry. The operator is deprecated in favor of the configuration object form of retry, which covers most cases.

Why retry resubscribes the whole source. The operator does not resume the source from where it failed — it subscribes again from the beginning. For an HTTP request, this is a new request. For a stream with side effects, the side effects are repeated. The operator assumes the source is safe to resubscribe, which is why it is used with HTTP and not with streams that have already performed side effects.


Placement of catchError

The position of catchError in the pipeline determines which errors it catches. This is the single most common source of confusion in RxJS error handling.

// Wrong: catchError before switchMap
source$.pipe(
  catchError(() => of([])),  // catches errors from source$ only
  switchMap((v) => this.http.get(`/api/${v}`)),  // inner errors not caught
);

// Right: catchError after switchMap
source$.pipe(
  switchMap((v) => this.http.get(`/api/${v}`)),
  catchError(() => of([])),  // catches inner errors
);

The first version catches errors from source$, which is the outer stream. The inner HTTP error from switchMap propagates past the catchError because the catchError was applied before the switchMap in the pipeline. The second version catches the inner error because the catchError is downstream of the switchMap.

Why the order matters. The pipeline is applied left to right. Each operator transforms the stream and passes it to the next. A catchError at position N catches errors from the operators before position N. An error from an operator after position N has not been produced yet when the catchError is applied.

Why the inner error is the one that matters. In Angular, the source of errors is usually an HTTP request inside a switchMap or mergeMap. The outer stream is a form control, a route parameter, or an event — it rarely errors. The catchError must be after the flattening operator to catch the HTTP error.

Why a catchError inside the inner stream is also valid. Sometimes the error should be handled at the level of the inner Observable.

source$.pipe(
  switchMap((v) =>
    this.http.get(`/api/${v}`).pipe(
      catchError(() => of(null)),  // inner recovery
    ),
  ),
);

The inner catchError recovers the error without terminating the outer stream. The outer stream continues to emit for subsequent values. This is the pattern when a single failed request should not tear down the whole pipeline.

Why the difference matters. An inner catchError recovers the individual request and the outer stream continues. An outer catchError recovers the whole pipeline and the outer stream terminates. The choice depends on whether subsequent outer values should still be processed.

Why the pattern is a common interview question. The placement of catchError is the classic RxJS error handling question because it tests the understanding of how the pipeline is composed. The rule is: the catchError is applied to the stream it is piped into, and it catches errors from everything upstream of it.

Why the inner catchError is the pattern for resilient pipelines. A search that fails one request should not stop working for the next query. The inner catchError recovers the individual request, and the outer stream continues. The outer catchError is for the cases where the whole pipeline should stop on the first error.


Errors in combineLatest and forkJoin

The combining operators have their own error behavior. Understanding it prevents the surprise of a combined stream erroring when one source fails.

combineLatest errors when any source errors. The combined stream propagates the error and terminates. The other sources are unsubscribed.

combineLatest([a$, b$]).subscribe({
  error: (e) => console.log('combined error:', e),
});

If a$ errors, the combined stream errors, and b$ is unsubscribed. This is the standard behavior for all the combining operators.

forkJoin errors when any source errors. The combined stream errors and does not emit. If any of the sources fails, the whole operation fails. This is the Promise.all behavior.

Why per-source error handling is needed. A combined stream that should survive one source failing needs each source to have its own catchError.

combineLatest([
  a$.pipe(catchError(() => of(defaultA))),
  b$.pipe(catchError(() => of(defaultB))),
]);

Each source recovers independently, and the combined stream continues. This is the pattern for a view model where one input can fail without breaking the view.

Why forkJoin needs the same treatment. A forkJoin of several HTTP requests fails if any request fails. If the consumer wants the successful results and a default for the failed ones, each request needs a catchError.

forkJoin({
  user: this.http.get('/api/user').pipe(catchError(() => of(null))),
  items: this.http.get('/api/items').pipe(catchError(() => of([]))),
});

The user is null if the request failed, and the items is [] if the request failed. The forkJoin completes with both values. This is the resilient pattern.

Why the recovery shape must match the type. The catchError recovery must return a value of the same type as the source. of(null) for a source that produces User | null, or of([]) for a source that produces an array. A mismatched type is a compile error, which is the type system helping.


Errors and cleanup

The finalize operator runs when the stream terminates for any reason: complete, error, or unsubscribe. It is the tool for cleanup that must happen regardless of the outcome.

this.http.get('/api/data').pipe(
  finalize(() => this.loading.set(false)),
);

The loading flag is set to false whether the request succeeds, fails, or is unsubscribed. Without finalize, the flag would remain true on error, and the UI would show a spinner forever.

Why finalize is the cleanup operator. It runs on every termination path, which is what cleanup needs. The alternative is to handle each path separately, which is error-prone. The finalize is the single place for the cleanup.

Why finalize runs on unsubscribe. When a subscription is unsubscribed — by takeUntilDestroyed, by a take, or by a manual unsubscribe — the finalize runs. This is what makes it correct for releasing resources, clearing timeouts, and resetting flags.

Why the order of finalize and catchError matters. The finalize runs after the catchError if it is placed after it, and before if it is placed before. The placement determines whether the cleanup happens before or after the recovery.

source$.pipe(
  catchError((e) => of(fallback)),
  finalize(() => cleanup()),
);

The catchError recovers, and the finalize runs when the recovered stream completes. This is the standard order.

Why finalize should not throw. If the cleanup throws, the error propagates and the original error is lost. The cleanup should be defensive, and any errors within it should be handled locally.


Complete Example Session

import { Component, DestroyRef, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { of, throwError, EMPTY, combineLatest, forkJoin } from 'rxjs';
import {
  catchError, retry, finalize, switchMap, mergeMap, debounceTime,
  distinctUntilChanged, takeUntilDestroyed, startWith,
} from 'rxjs/operators';

// ============================================
// PART 1: RECOVER WITH A FALLBACK
// ============================================

@Component({ selector: 'app-list', standalone: true, template: `` })
export class ListComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  readonly items$ = this.http.get<Item[]>('/api/items').pipe(
    catchError(() => of([])),
    takeUntilDestroyed(this.destroyRef),
  );
}

// ============================================
// PART 2: RETHROW AFTER LOGGING
// ============================================

@Component({ selector: 'app-detail', standalone: true, template: `` })
export class DetailComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  readonly user$ = this.http.get<User>('/api/user').pipe(
    catchError((error: HttpErrorResponse) => {
      console.error('Failed to load user', error.status);
      return throwError(() => error);
    }),
    takeUntilDestroyed(this.destroyRef),
  );
}

// ============================================
// PART 3: TRANSFORM THE ERROR
// ============================================

this.http.get<User>('/api/user').pipe(
  catchError((error: HttpErrorResponse) => {
    if (error.status === 404) return of(null);
    if (error.status === 403) return throwError(() => new Error('Not authorized'));
    return throwError(() => new Error('Could not load user'));
  }),
);

// ============================================
// PART 4: RETRY WITH BACKOFF
// ============================================

this.http.get('/api/data').pipe(
  retry({ count: 3, delay: (attempt) => Math.pow(2, attempt) * 1000 }),
  catchError((error) => throwError(() => error)),
);

// ============================================
// PART 5: CORRECT PLACEMENT OF catchError
// ============================================

// Wrong — catches the outer stream only
source$.pipe(
  catchError(() => of([])),
  switchMap((v) => this.http.get(`/api/${v}`)),
);

// Right — catches the inner error
source$.pipe(
  switchMap((v) => this.http.get(`/api/${v}`)),
  catchError(() => of([])),
);

// ============================================
// PART 6: INNER catchError FOR RESILIENCE
// ============================================

@Component({ selector: 'app-search', standalone: true, imports: [ReactiveFormsModule], template: `<input [formControl]="search" />` })
export class SearchComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);
  readonly search = new FormControl('', { nonNullable: true });

  readonly results$ = this.search.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    startWith(''),
    switchMap((term) =>
      this.http.get<Result[]>(`/api/search?q=${term}`).pipe(
        catchError(() => of([])),  // inner recovery — outer continues
      ),
    ),
    takeUntilDestroyed(this.destroyRef),
  );
}

// ============================================
// PART 7: COMBINELATEST WITH PER-SOURCE RECOVERY
// ============================================

readonly vm$ = combineLatest([
  this.user$.pipe(catchError(() => of(null))),
  this.items$.pipe(catchError(() => of([]))),
]).pipe(
  map(([user, items]) => ({ user, items })),
);

// ============================================
// PART 8: FORKJOIN WITH PER-SOURCE RECOVERY
// ============================================

forkJoin({
  user: this.http.get<User>('/api/user').pipe(catchError(() => of(null))),
  items: this.http.get<Item[]>('/api/items').pipe(catchError(() => of([]))),
});

// ============================================
// PART 9: FINALIZE FOR CLEANUP
// ============================================

@Component({ selector: 'app-loading', standalone: true, template: `` })
export class LoadingComponent {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  readonly loading = signal(false);

  load(): void {
    this.loading.set(true);
    this.http.get('/api/data').pipe(
      catchError(() => of(null)),
      finalize(() => this.loading.set(false)),
      takeUntilDestroyed(this.destroyRef),
    ).subscribe();
  }
}

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't place catchError before switchMap and expect it to catch inner errors
// source$.pipe(catchError(() => of([])), switchMap(...))

// Don't swallow errors silently
// catchError(() => EMPTY)  // the caller never knows

// Don't retry non-idempotent operations
// retry(3) on a POST creates duplicates

// Don't forget finalize for loading flags
// The flag stays true on error

// Don't assume combineLatest survives one source failing
// It errors when any source errors

// Don't resubscribe without a limit
// retry() without a count retries forever

The ten parts cover the error channel, recovery, rethrow, transformation, retry, placement, inner recovery, combining operators, cleanup, and the anti-patterns.


Quick Reference

Error Operators

OperatorPurpose
catchError(fn)Intercept and recover or rethrow
retry(n)Resubscribe n times
retry({ count, delay })Retry with delay
finalize(fn)Cleanup on any termination
throwError(fn)Create an error Observable
EMPTYComplete without emitting
of(value)Emit a value and complete

catchError Return Values

ReturnEffect
of(fallback)Recover with a value
throwError(() => e)Rethrow
EMPTYComplete silently
Inner ObservableContinue with a new stream

Retry Configuration

FormEffect
retry(3)3 retries, immediate
retry({ count: 3 })3 retries, immediate
retry({ count: 3, delay: 1000 })3 retries, 1s apart
retry({ count: 3, delay: (i) => 2 ** i * 1000 })Exponential

Placement Rules

RuleReason
After switchMapCatches inner errors
Before switchMapCatches outer errors only
Inside the inner pipeRecovers per request
After retryCatches the final failure

Combining Operators

OperatorError behavior
combineLatestErrors when any source errors
forkJoinErrors when any source errors
mergeErrors when any source errors
concatErrors when the current source errors

Best Practices

✅ Do This:

// Place catchError after the flattening operator
switchMap((v) => http.get(`/api/${v}`)), catchError(() => of([])) // ✅

// Use inner catchError for per-request resilience
switchMap((v) => http.get(`/api/${v}`).pipe(catchError(() => of(null)))) // ✅

// Retry with backoff for transient failures
retry({ count: 3, delay: (i) => 2 ** i * 1000 })                // ✅

// Recover with a fallback of the correct type
catchError(() => of([]))                                        // ✅

// Rethrow after logging when the caller needs to know
catchError((e) => { log(e); return throwError(() => e); })      // ✅

// Use finalize for cleanup
finalize(() => this.loading.set(false))                         // ✅

// Add per-source recovery in combineLatest
combineLatest([a$.pipe(catchError(() => of(null))), b$])        // ✅

// Use takeUntilDestroyed for cleanup
.pipe(takeUntilDestroyed(this.destroyRef))                      // ✅

❌ Don’t Do This:

// Don't place catchError before the flattening operator
catchError(() => of([])), switchMap(...)  // misses inner errors  // ⚠️

// Don't swallow errors silently
catchError(() => EMPTY)  // the caller never knows                // ⚠️

// Don't retry non-idempotent operations
retry(3)  // on a POST creates duplicates                         // ⚠️

// Don't retry without a limit
retry()  // retries forever                                       // ⚠️

// Don't forget finalize for loading flags
// The flag stays true on error                                   // ⚠️

// Don't assume combineLatest survives one source failing
// It errors on any source error                                  // ⚠️

// Don't recover with the wrong type
catchError(() => of(''))  // for a User source                    // ⚠️

// Don't ignore the error object
catchError(() => of([]))  // the status is lost                   // ⚠️

Common Pitfalls

PitfallProblemSolution
catchError before switchMapInner errors escapePlace after
Swallowed errorCaller never knowsRethrow or recover with logging
retry on POSTDuplicatesIdempotent only
Unlimited retryServer overloadSet a count
Missing finalizeLoading flag stuckAdd finalize
combineLatest without per-source recoveryOne failure kills allAdd catchError per source
Wrong recovery typeCompile error or wrong dataMatch the source type
EMPTY as a silent recoveryNo notificationRethrow or log

Real-World Examples

1. Recover with a fallback

this.http.get<Item[]>('/api/items').pipe(catchError(() => of([])))

2. Rethrow after logging

catchError((e) => { this.logger.error(e); return throwError(() => e); })

3. Transform a 404 to null

catchError((e: HttpErrorResponse) => e.status === 404 ? of(null) : throwError(() => e))

4. Retry with backoff

retry({ count: 3, delay: (i) => 2 ** i * 1000 })

5. Correct placement

switchMap((v) => http.get(`/api/${v}`)), catchError(() => of([]))

6. Inner recovery

switchMap((v) => http.get(`/api/${v}`).pipe(catchError(() => of(null))))

7. Per-source recovery in combineLatest

combineLatest([user$.pipe(catchError(() => of(null))), items$])

8. Resilient forkJoin

forkJoin({
  user: http.get('/api/user').pipe(catchError(() => of(null))),
  items: http.get('/api/items').pipe(catchError(() => of([]))),
})

9. Cleanup with finalize

finalize(() => this.loading.set(false))

10. Complete pipeline

this.http.get<Item[]>('/api/items').pipe(
  retry({ count: 2, delay: 1000 }),
  catchError((e) => { this.logger.error(e); return of([]); }),
  finalize(() => this.loading.set(false)),
  takeUntilDestroyed(this.destroyRef),
)

Visual: The Error Channel

┌──────────────────────────────────────────────────────────┐
│  Observable                                              │
│    │                                                     │
│    ├── next(1)                                           │
│    ├── next(2)                                           │
│    ├── error(new Error('x'))  ──► TERMINAL               │
│    │                                                     │
│    │  OR                                                  │
│    │                                                     │
│    └── complete()  ──► TERMINAL                          │
│                                                          │
│  After error or complete, no further notifications.      │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: catchError Placement

┌──────────────────────────────────────────────────────────┐
│  WRONG                                                   │
│                                                          │
│  source$ ──► catchError ──► switchMap ──► subscriber     │
│                  ▲                                       │
│                  │                                       │
│             catches source$ errors                       │
│             inner errors pass through                    │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  RIGHT                                                   │
│                                                          │
│  source$ ──► switchMap ──► catchError ──► subscriber     │
│                                ▲                         │
│                                │                         │
│                          catches inner errors            │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Retry with Backoff

┌──────────────────────────────────────────────────────────┐
│  Attempt 1 ──► fails                                     │
│       │                                                  │
│       ▼  wait 2^1 * 1000 = 2000 ms                       │
│  Attempt 2 ──► fails                                     │
│       │                                                  │
│       ▼  wait 2^2 * 1000 = 4000 ms                       │
│  Attempt 3 ──► fails                                     │
│       │                                                  │
│       ▼  wait 2^3 * 1000 = 8000 ms                       │
│  Attempt 4 ──► fails                                     │
│       │                                                  │
│       ▼                                                  │
│  error propagates to catchError                          │
│                                                          │
│  The delays grow, reducing load on the server.           │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Inner vs Outer catchError

┌──────────────────────────────────────────────────────────┐
│  OUTER catchError                                        │
│                                                          │
│  source$ ──► switchMap ──► catchError ──► complete       │
│                                                          │
│  One error terminates the whole pipeline.                │
│  Subsequent source$ values are not processed.            │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  INNER catchError                                        │
│                                                          │
│  source$ ──► switchMap(inner$.pipe(catchError)) ──►      │
│                                                          │
│  Each inner error is recovered individually.             │
│  The outer stream continues for subsequent values.       │
│                                                          │
│  Search that fails one request still works for the next. │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: combineLatest with Per-Source Recovery

┌──────────────────────────────────────────────────────────┐
│  WITHOUT per-source recovery                             │
│                                                          │
│  a$ ──► error ──► combined stream errors ──► subscriber  │
│  b$ ──► unsubscribed                                     │
│                                                          │
│  One failure kills the view model.                       │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  WITH per-source recovery                                │
│                                                          │
│  a$.pipe(catchError(() => of(null))) ──► null            │
│  b$ ──► values                                           │
│                                                          │
│  combined emits [null, bValue]                           │
│  The view model continues with a default for a$.         │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: finalize on Every Path

┌──────────────────────────────────────────────────────────┐
│  source$ ──► finalize(cleanup) ──► subscriber            │
│                                                          │
│  Terminates by:                                          │
│    ├── complete     ──► cleanup runs                     │
│    ├── error        ──► cleanup runs                     │
│    └── unsubscribe  ──► cleanup runs                     │
│                                                          │
│  The cleanup runs on every path.                         │
│  Loading flags, timeouts, and resources are released.    │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

OperatorPurposeTerminates
catchErrorRecover or rethrowNo (if recovered)
retryResubscribeNo (until count exhausted)
finalizeCleanupRuns on all paths
throwErrorCreate error streamYes
EMPTYComplete silentlyYes
of(value)Emit and completeYes
PlacementCatches
Before switchMapOuter errors
After switchMapInner errors
Inside inner pipePer-request errors
After retryFinal failure

Key takeaways:

  • The error channel is terminal — once an Observable errors, it is finished, and no further values or completion are delivered
  • catchError intercepts the error and returns a new Observable — recovery continues the stream with the new Observable’s values; rethrow propagates the error
  • The return value must be an Observable — of(value) for recovery, throwError(() => e) for rethrow, EMPTY for a silent completion
  • retry resubscribes to the source — the count and delay control the number and timing of attempts, and the delay function produces exponential backoff
  • retry is for idempotent operations — a GET can be retried; a POST that creates a resource cannot
  • The placement of catchError determines what it catches — before a switchMap catches outer errors, after catches inner errors, and inside the inner pipe recovers per request
  • Inner recovery is the pattern for resilient pipelines — a failed request is recovered without terminating the outer stream, so subsequent values are still processed
  • combineLatest and forkJoin error when any source errors — per-source catchError is needed for the combined stream to survive one source failing
  • finalize runs on every termination path — complete, error, and unsubscribe all trigger the cleanup, which is why it is the tool for loading flags and resource release
  • The error object carries the information needed to handle — the status, the body, and the context, and discarding it makes the handler unable to decide

Remember: Error handling in RxJS is about the error channel, which is terminal and propagates upstream. catchError is the operator that intercepts it, and its placement determines what it catches. retry resubscribes for transient failures, and finalize cleans up on every path. The most common mistake is placing catchError before the flattening operator, which misses the inner errors that are usually the ones that matter. The most common improvement is per-source recovery, which makes a combined stream survive one source failing.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!