skip to content

An RxJS subscribe() call passes only a next callback and the source errors - what happens, and why does a surrounding try/catch not catch it?

level: seniorimportance: nice to knowfreq 30%

answer

  1. partial observer, missing error handler
  2. reported on a new call stack
  3. config.onUnhandledError
  4. producer interference in multicasts

basics

~20 s

RxJS 7 treats the error as unhandled: the subscription closes and runs its teardown, and the error is rethrown asynchronously in a setTimeout, or passed to config.onUnhandledError. A try/catch around subscribe() has already exited, so it never sees it.

solid answer

~40 s

When an observer has no `error` callback, RxJS 7 hands the error to `reportUnhandledError`, which schedules it on a new call stack with `setTimeout`: if `config.onUnhandledError` is set it calls that, otherwise it rethrows so the runtime's global handler sees it. The subscription still ends normally, so its teardown runs. A `try/catch` around `subscribe()` misses it because the rethrow happens after the `try` block has exited - even when the source errors synchronously. Exceptions thrown inside the observer's own `next`, `error` or `complete` callbacks take the same asynchronous route rather than reaching that observer's `error` callback. The design prevents **producer interference**: one consumer's thrown error cannot break a shared source for the others. The fix is to pass an observer with `error`, or handle the failure upstream.

code

ts · 16 lines
ts
import { config, throwError } from 'rxjs';

// development-only hook: called asynchronously for unhandled errors
config.onUnhandledError = (err) => console.warn('unhandled RxJS error', err);

try {
  throwError(() => new Error('boom')).subscribe((v) => console.log(v));
} catch {
  console.log('never printed: the error is reported later, not thrown here');
}

// the handled form
throwError(() => new Error('boom')).subscribe({
  next: (v) => console.log(v),
  error: (err) => console.error('handled', err),
});

go deeper

for a junior

Recall that every subscribe() that can fail should pass an observer with an error callback, and that try/catch around subscribe() does not catch stream errors.

for a middle

Explain that RxJS 7 rethrows unhandled errors on a new call stack or passes them to config.onUnhandledError, and that the subscription still closes and tears down.

for a senior

Diagnose uncaught errors with timer-rooted stacks and silently frozen streams, explain producer interference, and choose between handling at the subscriber and recovering upstream.

for a principal

Set a codebase-wide policy: a lint or review rule for subscriptions without error handling, a development-time onUnhandledError hook, and a clear owner for recovery in each stream.

## What happens step by step When you call `source$.subscribe(value => ...)` with a single function, RxJS wraps it in a `SafeSubscriber` whose partial observer has a `next` but no `error`. If the source then calls `subscriber.error(err)`: 1. The `Subscriber` marks itself stopped and forwards the error to its consumer wrapper. 2. The wrapper sees there is no `error` callback and calls RxJS's internal `reportUnhandledError(err)`. 3. `reportUnhandledError` schedules a callback with `setTimeout`. In that callback, if **`config.onUnhandledError`** is set, RxJS calls it with the error; otherwise it **rethrows** the error so the runtime's uncaught-error mechanism - `window.onerror`, an `error` event, or Node's `uncaughtException` - picks it up. 4. Independently, the `Subscriber` unsubscribes itself, so the subscription is **closed and its teardown runs**, exactly as when an error is handled. The error is never swallowed; it is moved to a place where it cannot interfere with library code. ## Why try/catch does not see it A `try { source$.subscribe(fn) } catch {}` block only catches exceptions thrown **while `subscribe()` is executing**. The rethrow happens later, in a timer callback, on a fresh call stack whose caller is the event loop. By then the `try` block has finished. This is true even when the source errors synchronously inside `subscribe()`: the notification is processed synchronously, but the rethrow is deliberately deferred. Older RxJS versions (5.x) threw such errors synchronously. RxJS 7 keeps a flag, `config.useDeprecatedSynchronousErrorHandling`, to restore that for migrations; it is deprecated, and its own documentation says it enables bad patterns such as wrapping `subscribe()` in `try/catch`. ## Errors thrown inside observer callbacks The same route applies when **your own callback throws**: - an exception thrown in `next` is caught by the wrapper and reported asynchronously - it is **not** delivered to the same observer's `error` callback; - an exception thrown in the `error` or `complete` callback is reported the same way. This surprises people who expect `subscribe({ next: n => { throw ... }, error: e => ... })` to route the throw into their own `error` handler. It does not. An exception thrown in an **operator's** callback, such as the function given to `map`, is different: it becomes an `error` notification that flows downstream to whatever handles errors there. ## Why RxJS chose this design: producer interference Consider a multicast source that loops over its observers and calls `next` on each. If observer two threw synchronously and the exception propagated, the loop would abort and observers three and four would never get the value - one consumer's bug would break the stream for everyone. RxJS calls this **producer interference**. Reporting consumer errors on a separate call stack keeps the producer's loop intact, which is the reason the configuration docs give for keeping the synchronous mode deprecated. ## Diagnosing it in production Symptoms of an unhandled observer error: - an uncaught exception in the error-reporting tool whose stack starts in a timer callback rather than in application code; - a stream that silently stops updating the screen, because the error closed the subscription; - in tests, a failure that surfaces in a later test or as a global error rather than in the test that caused it. Useful steps: - set `config.onUnhandledError` (imported from `'rxjs'`) in development to log the error with context; - search for `subscribe(` calls given a lone function or an observer without `error`; - decide per stream whether failure should be handled at the subscriber or recovered upstream. ## In tests Unhandled errors are especially confusing in test suites: the rethrow runs in a timer after the test body has finished, so the failure may be attributed to a later test or reported as a global error. Setting `config.onUnhandledError` in test setup to record errors, and asserting that none were recorded, pins the failure to the test that caused it. ## The fix Pass a full observer, `subscribe({ next, error })`, when the subscriber is the right place to react - showing a message, for example. When the stream should **recover** - fall back to a cached value, retry, or continue - handle the error upstream with RxJS's error-handling operators, which is a separate topic. Note that `subscribe(next, error)` with separate function arguments still works in RxJS 7 but that signature is deprecated; the observer object is the current form.

  • In RxJS 7, if the next callback passed to subscribe() throws, does the same observer's error callback receive it?
    No. RxJS wraps each observer callback in a try/catch and sends a thrown exception to `reportUnhandledError`, so it surfaces asynchronously as an unhandled error, or through `config.onUnhandledError`. The observer's `error` callback is reserved for errors the source sends. An exception thrown inside an operator callback such as `map` is different: it becomes an error notification downstream.
  • In RxJS, what is producer interference, and how does asynchronous error reporting prevent it?
    It is one consumer's thrown exception breaking the producer's delivery to other consumers - for example, a subject's loop over its observers aborting when observer two throws. Because RxJS 7 catches consumer exceptions and rethrows them on a new call stack, the loop completes and every other observer still receives the value.

saying these in an interview costs you the question

  • An observer without an error callback means RxJS silently swallows the error.
  • Wrapping subscribe() in try/catch is a reliable way to handle stream errors.
  • An exception thrown in the next callback is routed to the same observer's error callback.
  • An unhandled error leaves the subscription open, so teardown never runs.
  • useDeprecatedSynchronousErrorHandling is the recommended way to get catchable errors.