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
catchErrorin 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
catchErroris the pattern for resilient pipelines. A search that fails one request should not stop working for the next query. The innercatchErrorrecovers the individual request, and the outer stream continues. The outercatchErroris 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
| Operator | Purpose |
|---|---|
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 |
EMPTY | Complete without emitting |
of(value) | Emit a value and complete |
catchError Return Values
| Return | Effect |
|---|---|
of(fallback) | Recover with a value |
throwError(() => e) | Rethrow |
EMPTY | Complete silently |
| Inner Observable | Continue with a new stream |
Retry Configuration
| Form | Effect |
|---|---|
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
| Rule | Reason |
|---|---|
After switchMap | Catches inner errors |
Before switchMap | Catches outer errors only |
| Inside the inner pipe | Recovers per request |
After retry | Catches the final failure |
Combining Operators
| Operator | Error behavior |
|---|---|
combineLatest | Errors when any source errors |
forkJoin | Errors when any source errors |
merge | Errors when any source errors |
concat | Errors 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
| Pitfall | Problem | Solution |
|---|---|---|
catchError before switchMap | Inner errors escape | Place after |
| Swallowed error | Caller never knows | Rethrow or recover with logging |
retry on POST | Duplicates | Idempotent only |
Unlimited retry | Server overload | Set a count |
Missing finalize | Loading flag stuck | Add finalize |
combineLatest without per-source recovery | One failure kills all | Add catchError per source |
| Wrong recovery type | Compile error or wrong data | Match the source type |
EMPTY as a silent recovery | No notification | Rethrow 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
| Operator | Purpose | Terminates |
|---|---|---|
catchError | Recover or rethrow | No (if recovered) |
retry | Resubscribe | No (until count exhausted) |
finalize | Cleanup | Runs on all paths |
throwError | Create error stream | Yes |
EMPTY | Complete silently | Yes |
of(value) | Emit and complete | Yes |
| Placement | Catches |
|---|---|
Before switchMap | Outer errors |
After switchMap | Inner errors |
| Inside inner pipe | Per-request errors |
After retry | Final failure |
Key takeaways:
- The error channel is terminal — once an Observable errors, it is finished, and no further values or completion are delivered
catchErrorintercepts 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,EMPTYfor a silent completion retryresubscribes to the source — the count and delay control the number and timing of attempts, and the delay function produces exponential backoffretryis for idempotent operations — aGETcan be retried; aPOSTthat creates a resource cannot- The placement of
catchErrordetermines what it catches — before aswitchMapcatches 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
combineLatestandforkJoinerror when any source errors — per-sourcecatchErroris needed for the combined stream to survive one source failingfinalizeruns 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!