skip to content

In RxJS, when exactly does the teardown returned from a new Observable subscriber function run, and what happens to next() calls afterwards?

level: middleimportance: should knowfreq 42%

answer

  1. three ways a subscription ends
  2. observer callback first, cleanup second
  3. a stopped Subscriber drops notifications
  4. add() on a closed subscription

basics

~20 s

RxJS runs the teardown once, on whichever comes first: unsubscribe(), error() or complete(); on error or complete it runs after the observer's callback. Afterwards the Subscriber is stopped, and later next, error or complete calls are silently ignored.

solid answer

~40 s

The teardown runs **once**, when the subscription ends by any of three routes: the consumer calls `unsubscribe()`, the producer calls `subscriber.error()`, or it calls `subscriber.complete()`. On the two terminal routes the `Subscriber` first delivers the notification to the observer, then unsubscribes itself, which runs the teardown. `unsubscribe()` is idempotent, so a second call does nothing. Once stopped, the `Subscriber` ignores further `next`, `error` and `complete` calls; by default they are no-ops, and `config.onStoppedNotification` can be set to observe them. A producer looping synchronously should check `subscriber.closed` to stop early. If the function completes synchronously before returning its teardown, RxJS runs that teardown immediately when it is added.

code

ts · 23 lines
ts
import { Observable } from 'rxjs';

const ticks$ = new Observable<number>((subscriber) => {
  let n = 0;
  const id = setInterval(() => {
    subscriber.next(n++);
    if (n === 3) {
      subscriber.complete();
    }
  }, 100);
  return () => {
    console.log('teardown');
    clearInterval(id);
  };
});

const sub = ticks$.subscribe({
  next: (v) => console.log('next', v),
  complete: () => console.log('complete'),
});

// next 0, next 1, next 2, complete, teardown
setTimeout(() => sub.unsubscribe(), 1000); // no second 'teardown'

go deeper

for a junior

Recall that the function returned from new Observable is the cleanup, and that RxJS calls it when the subscription ends.

for a middle

Explain the three endings, the order of observer callback then teardown on error and complete, idempotent unsubscribe, and that late next() calls are ignored.

for a senior

Diagnose leaky custom producers: late notifications are dropped but the work continues, synchronous loops need subscriber.closed, and onStoppedNotification can expose the culprit in development.

for a principal

Argue for a team rule that every custom Observable owns its cleanup in teardown, reviewed like resource handling in any other language, instead of relying on consumers to remember.

## Three ways a subscription ends An RxJS subscription ends in exactly one of three ways, and the **teardown** - the function (or `Subscription`, or object with `unsubscribe()`) that the subscriber function returned - runs on all of them: | Ending | Who triggers it | Observer callback called | Teardown runs | |---|---|---|---| | `unsubscribe()` | the consumer | none | yes, immediately | | `subscriber.error(err)` | the producer | `error` | yes, after the callback | | `subscriber.complete()` | the producer | `complete` | yes, after the callback | The detail people miss is the terminal routes. In RxJS 7's `Subscriber`, `error()` and `complete()` first set an internal stopped flag, then forward the notification to the observer inside a `try`, and in the `finally` block call `this.unsubscribe()`. So the observer sees `complete` **before** the cleanup runs, and a producer never needs to clean up by hand before signalling the end. ## Exactly once `Subscription.unsubscribe()` checks its `closed` flag. The first call sets it and runs the finalizers; every later call returns without doing anything. That is why a consumer can safely call `unsubscribe()` on a subscription that already completed - the teardown will not run a second time. If a finalizer throws, RxJS keeps running the remaining finalizers, collects the failures, and throws a single `UnsubscriptionError` at the end, so one broken cleanup does not skip the others. ## Notifications after the end are dropped After `error`, `complete` or `unsubscribe`, the `Subscriber` is **stopped**. If the producer keeps calling `next()` - say a timer it forgot to clear - those calls do not reach the observer: - by default they are silent no-ops; - if `config.onStoppedNotification` is set (import `config` from `'rxjs'`), RxJS calls it asynchronously with the dropped notification, which is useful for spotting leaky producers in development. The drop protects the consumer but not the resource: a timer that was never cleared still fires. The contract's guarantee is only that the observer is not called; stopping the work is the teardown's job. ## The synchronous edge cases Two situations surprise people because the subscriber function runs synchronously inside `subscribe()`: 1. **Completing before returning the teardown.** RxJS attaches the returned teardown with `subscriber.add(...)` after the function returns. If the function has already called `complete()`, the subscriber is closed by then, and `Subscription.add()` on a closed subscription **executes the finalizer immediately**. The cleanup still happens; it just happens at the end of `subscribe()`. 2. **A synchronous producer and an early unsubscribe.** A loop such as `for (let i = 0; i < 1_000_000; i++) subscriber.next(i)` keeps running even if a downstream `take(1)` has already unsubscribed, because the loop never yields. Checking `subscriber.closed` in the loop condition lets it stop as soon as nobody is listening. A related rule: if the subscriber function **throws synchronously**, RxJS catches the exception and delivers it as an `error` notification, so it ends the subscription like any other error rather than escaping from `subscribe()`. ## Unsubscribe is not complete From the consumer's side, the observer's `complete` callback fires only when the **producer** finishes. When the consumer calls `unsubscribe()`, no observer callback fires at all - the teardown runs, and that is the whole event. Code that puts cleanup in the consumer's `complete` callback therefore misses the most common ending in UI code, where screens unsubscribe long before a stream would complete. Cleanup that must happen on every ending belongs in the producer's teardown. ## Registering several resources Because `Subscriber` extends `Subscription`, a producer can call `subscriber.add(teardown)` as it acquires each resource instead of returning one combined function. In RxJS 7 `add()` returns `void` - the v6 behaviour of returning a `Subscription` was removed as a breaking change - and it accepts functions, `Subscription` objects or anything with `unsubscribe()`. ## What to say in the interview - The teardown runs on unsubscribe, error and complete - not only on unsubscribe. - On error and complete, the observer's callback runs first and the teardown second. - It runs once; repeated `unsubscribe()` calls are harmless. - Late notifications are ignored, but a producer must still stop its own work, and `subscriber.closed` is how a synchronous producer notices. These points separate someone who has written custom Observables from someone who has only consumed them.

  • In RxJS, why should a synchronous producer loop check subscriber.closed?
    Because the loop never yields, an operator such as `take(1)` downstream can unsubscribe after the first value while the loop keeps calling `next()` a million times. The calls are dropped, but the work still runs. Checking `!subscriber.closed` in the loop condition lets the producer stop as soon as the consumer has gone.
  • In RxJS 7, what does Subscription.add() return, and what happens if the subscription is already closed?
    It returns `void`; returning a `Subscription` was RxJS 6 behaviour and was removed in 7. If the target subscription is already closed, `add()` executes the finalizer immediately rather than storing it, so late-registered cleanup still runs. Adding a subscription to itself, or adding `null`, does nothing.

saying these in an interview costs you the question

  • Teardown only runs when the consumer explicitly calls unsubscribe().
  • On complete, RxJS runs the teardown before the observer's complete callback.
  • Calling unsubscribe() twice runs the teardown twice.
  • next() after complete() still reaches the observer if the teardown has not run yet.
  • Dropping late notifications means the producer's timer or listener is stopped automatically.
  • An exception thrown inside the subscriber function escapes from subscribe() to the caller.