The Observable Contract#
Every Observable follows one simple grammar, often written as:
In words: zero or more next notifications, optionally followed by exactly one terminal notification, either error or complete, never both. After a terminal notification, nothing else is ever delivered to that subscriber.
This contract is what makes RxJS composable: every operator can rely on it, and every operator preserves it.
The Guarantees#
- Values stop after termination. Once
errororcompletehas been delivered, no furthernext(or anything else) reaches the subscriber, even if a badly behaved producer keeps pushing. - Error and complete are mutually exclusive. A stream ends in success (
complete) or failure (error), never in both, and never twice. - Teardown always runs. After a terminal notification, or on unsubscribe, the producer's cleanup logic executes exactly once.
RxJS enforces this at the Subscriber layer, so even hand-written new Observable(...) producers that misbehave are sanitized:
import { Observable } from "rxjs";
const rude$ = new Observable<number>((subscriber) => {
subscriber.next(1);
subscriber.complete();
subscriber.next(2); // ignored: contract enforced
subscriber.error(new Error("too late")); // also ignored
});
rude$.subscribe({
next: console.log,
complete: () => console.log("complete"),
});
// 1
// complete (the post-completion next and error are silently dropped)
The same rule is why a completed Subject ignores further next() calls.
Why Interviews Care#
The contract explains behaviors that otherwise look like trivia:
- Why an uncaught error kills a stream permanently, and why
catchErrormust swap in a new Observable rather than "resume" the old one. - Why
retryworks by resubscribing: the errored execution is unrecoverable by contract, so the only way forward is a fresh one. - Why placing error handling on the inner Observable of a
switchMapkeeps the outer stream alive: the inner stream's termination is contained, the outer stream never saw a terminal notification. - Why
forkJoincan trust "last value at completion" andlast()can exist at all.
Common Mistakes#
Treating error as just another value. It is a terminal state. Streams that must survive failures (form pipelines, polling loops) need errors converted to values (catchError returning a fallback) inside the pipeline.
Emitting after completion in custom Observables. RxJS drops the extra notifications, but the producer keeps doing wasted work. Return proper teardown and stop producing on termination.
Assuming unsubscribe is part of the grammar. Unsubscription is a consumer-side action, not a notification: no terminal event is delivered, handlers do not fire, only teardown (and finalize) run. See Subscription & Teardown.
Interview Q&A#
Can an Observable error twice, or error and then complete?
No. The contract allows at most one terminal notification. Anything a producer attempts after that is discarded by the Subscriber wrapper. This is guaranteed by RxJS itself, not left to producer discipline.
Why can't a stream simply continue after an error?
Because error is defined as termination, operators and subscribers everywhere rely on it: resources are torn down and state is released. Recovery therefore means replacement: catchError substitutes a new Observable, and retry resubscribes for a fresh execution.
Where should catchError go so one failure doesn't stop everything?
On the innermost stream whose death you can afford, typically the per-request inner Observable inside switchMap/mergeMap/concatMap. The inner termination satisfies the contract locally while the outer stream keeps emitting.
Next Up#
- Subscription & Teardown: the consumer-side half of the lifecycle
- catchError and retry, the recovery tools built on the contract
- Subject, where the same rules govern manual
next()/complete()calls