skip to content

In RxJS, what must a catchError selector return, and what does the subscriber see when it returns of(fallback), EMPTY or throwError?

level: middleimportance: must knowfreq 72%

answer

  1. an error ends the stream
  2. replace, do not resume
  3. must be an ObservableInput
  4. fallback, silence or a new error
  5. the caught argument loops

basics

~20 s

catchError's selector must return an observable input that replaces the errored source: of(fallback) delivers the fallback then completes, EMPTY just completes, and throwError(() => err) passes a new or translated error on; the failed source never resumes.

solid answer

~40 s

In RxJS an `error` notification is **terminal**: the source stops and nothing more arrives. `catchError((err, caught) => ...)` intercepts that error and **subscribes the subscriber to whatever the selector returns** — an observable, promise, array or other `ObservableInput`. So `of(fallback)` makes the subscriber see the fallback value and then **complete**; `EMPTY` makes it see only completion; `throwError(() => new PriceError(...))` replaces the error with another one. Returning nothing is itself a bug: `undefined` is not a stream, so RxJS raises a `TypeError`. The original source is never resumed — for a long-lived stream, a `catchError` at the end means the stream is over. The second argument, `caught`, is the source re-wrapped; returning it resubscribes, which loops forever on a persistent error.

code

ts · 13 lines
ts
import { EMPTY, catchError, throwError } from 'rxjs';

const broken$ = throwError(() => new Error('boom')).pipe(
  catchError((err) => {
    console.warn('price lookup failed', err);
    return EMPTY; // forgetting this return would raise a TypeError
  }),
);

broken$.subscribe({
  next: () => console.log('never'),
  complete: () => console.log('completed without a value'),
});

go deeper

for a junior

Recall that an error ends an observable and that catchError must return a replacement observable, such as of(fallback) or EMPTY.

for a middle

Explain what the subscriber sees for each return value, that the source is replaced rather than resumed, and why throwError takes a factory in RxJS 7.

for a senior

Keep long-lived streams alive by catching per inner request, avoid silent EMPTY recovery without logging, and order retry before catchError.

for a principal

Define how failures surface across features: which errors become fallbacks, which become domain errors, and where they must be recorded so recovery never hides outages.

## Errors are terminal An RxJS observable delivers three kinds of notifications: any number of `next` values, then at most one **terminal** notification — `error` or `complete`. After an error: - the source delivers nothing more; - operators downstream skip their value logic and pass the error along, until one that handles errors — such as `catchError` or `retry` — intercepts it; - the subscriber's `error` callback runs, and the subscription is closed. If nobody handles it, the error reaches the subscriber, and if the subscriber has no `error` callback, RxJS reports it as an unhandled error. ## What catchError does `catchError(selector)` sits in a `pipe` and intercepts an error from anything above it. The selector is called as `selector(err, caught)` and **must return an `ObservableInput`** — an observable, a promise, an array, an iterable and similar. `catchError` then: 1. unsubscribes from the failed source; 2. subscribes the downstream subscriber to the returned input instead; 3. from then on forwards whatever that replacement emits — values, completion or error. So `catchError` does not "resume" the source; it **replaces the rest of the stream**. What the subscriber sees depends only on what you return: | Selector returns | Subscriber sees | Use it when | |---|---|---| | `of(fallback)` | the fallback value, then completion | a default value is a meaningful answer | | `EMPTY` | completion only, no value | the failure should simply produce nothing | | `throwError(() => newErr)` | a different error | translating a low-level error into a domain error | | `throw err` inside the selector | the same error, rethrown | logging or side effects, then propagating | | `caught` | the source again, from the start | almost never — loops forever on a persistent error | | `undefined` (forgot to return) | a `TypeError` about an invalid stream | never; it is a bug | ```ts import { Observable, EMPTY, catchError, of, throwError } from 'rxjs'; interface Price { sku: string; amount: number | null } class PriceUnavailableError extends Error {} declare function fetchPrice(sku: string): Observable<Price>; // Fallback value: next({ amount: null }), then complete const withFallback$ = fetchPrice('A-100').pipe( catchError(() => of({ sku: 'A-100', amount: null })), ); // Swallow: complete only const orNothing$ = fetchPrice('A-100').pipe(catchError(() => EMPTY)); // Translate: a domain error reaches the subscriber const translated$ = fetchPrice('A-100').pipe( catchError((err) => throwError(() => new PriceUnavailableError(String(err)))), ); ``` ## throwError takes a factory `throwError(() => new Error('...'))` creates an observable that errors on subscription. RxJS 7 wants a **factory function**: the error object is then created at the moment it is thrown, per subscription, with a more useful stack trace. Passing the error value directly — `throwError(err)` — still works but is deprecated and slated for removal in v8. If you already hold an error object, wrap it: `throwError(() => err)`. ## Consequences people miss - **A fallback completes the stream.** `of(fallback)` completes right after its value. For a one-shot request that is fine. For a long-lived stream — a poll, a stream of user actions — a `catchError` at the outer level ends the whole stream after the first failure. - **Keep long-lived streams alive by catching per inner request.** When each action is mapped to a request, putting `catchError` on the **inner** request observable replaces only that request's failure, and the outer stream keeps running. - **`EMPTY` hides failures.** The subscriber sees a normal completion and cannot tell "no data" from "the backend failed". Log or record the error inside the selector before returning `EMPTY`. - **`caught` is a retry without limit.** Returning the second argument resubscribes to the source immediately and indefinitely. The `retry` operator does this job with a count and a delay. - **Errors thrown inside the selector propagate.** If the selector itself throws, that error goes downstream as the stream's error. ## Where catchError sits relative to retry `catchError` only sees errors that reach it. When combined with `retry`, place `retry` **above** `catchError`: retries happen first, and only the final error reaches the fallback. Put the other way round, `catchError` turns the error into a fallback and `retry` never sees an error to retry.

  • Why is throwError(err) deprecated in favour of throwError(() => err)?
    The factory form creates the error at the moment the observable errors, for each subscription, which gives a more accurate stack trace and a fresh error object per subscriber. Passing an error value is deprecated in RxJS 7 and slated for removal in v8. If you already have the error object, `throwError(() => err)` wraps it without change.
  • What happens if the catchError selector itself throws?
    The thrown value becomes the stream's error and goes downstream to the next error handler or the subscriber. That is a valid way to log and rethrow: `catchError((err) => { log(err); throw err; })`. It is equivalent to returning `throwError(() => err)`.
  • Why does a catchError at the end of a polling stream stop the polling after one failure?
    `catchError` replaces the errored stream with the returned observable. `of(fallback)` emits once and completes, so the whole polling stream completes. To keep polling, catch the error on each inner request instead, so only that request is replaced and the outer timer keeps running.

saying these in an interview costs you the question

  • catchError lets the original source continue emitting after the error
  • Returning a plain value from catchError emits that value
  • Returning EMPTY from catchError keeps the stream open for later values
  • throwError(err) with an error value is the recommended form in RxJS 7
  • Returning caught from catchError retries a limited number of times