Key Takeaways
- JavaScript observables can deliver zero or more values, synchronously or asynchronously; a promise represents one eventual outcome.
- RxJS observers receive next, error, and complete notifications. Error and complete are terminal: neither allows further notifications.
- Cold sources commonly start independent work per subscription. Hot producers can already be running when you subscribe.
- Unsubscribing disposes a subscription, but stopping underlying work requires producer cleanup, called teardown.
- Choose observables for stream composition and subscription lifecycles—not because they're universally better than promises.
An observable is a subscribable source that delivers values to an observer through callbacks. Unlike a promise, it can deliver multiple values, making it useful for events, changing data, and repeated asynchronous results. The important distinction is the notification and subscription model, not simply “more powerful promises.”
If you need a refresher on single-result asynchronous work, start with our JavaScript promises tutorial.
What Are JavaScript Observables?
ECMAScript does not currently define a built-in Observable type; RxJS supplies its own Observable class. You don't need a framework to use RxJS. Angular's HttpClient returns RxJS observables, which is one common place developers encounter them.
Keep three roles separate: the producer generates values, the observable exposes a subscription interface, and the observer supplies callbacks that consume notifications. The producer might be a timer, an HTTP request, or an existing event source.
The next, error, and complete contract
The RxJS Observable contract permits zero or more next notifications, followed by at most one terminal notification:
next(value)delivers a value.error(reason)ends the subscription with a failure.complete()ends the subscription successfully without delivering another value.
After error or complete, the observer receives no further notifications from that subscription. A stream can also remain open indefinitely without either terminal notification.
Promise: pending ---------------- fulfilled(value) OR rejected(reason)
Observable: next(a) -- next(b) ------ complete
next(a) ---------------- error(reason)
[zero or more next notifications; termination is optional]
Observable notifications may be synchronous or asynchronous.
These timelines illustrate ordering, not mandatory delays.Build a small Observable teaching sketch
This deliberately incomplete class makes the producer/observer relationship visible. It also returns a small subscription object so you can see where cleanup belongs. Run it in a browser console or Node.js; it has no dependencies.
// Teaching sketch only: this is NOT an RxJS implementation.
class Observable {
constructor(subscriber) {
this._subscriber = subscriber;
}
subscribe(observer) {
const teardown = this._subscriber(observer);
let unsubscribed = false;
return {
unsubscribe() {
if (unsubscribed) return;
unsubscribed = true;
teardown?.();
}
};
}
}
const myObservable = new Observable(observer => {
const timeoutId = setTimeout(() => {
observer.next("got data!");
observer.complete();
}, 1000);
return () => clearTimeout(timeoutId);
});
const myObserver = {
next(data) {
console.log(data);
},
error(error) {
console.error(error);
},
complete() {
console.log("request complete");
}
};
const subscription = myObservable.subscribe(myObserver);
// Normally logs "got data!", then "request complete" after the timer fires.
// Uncomment to cancel the pending timer:
// subscription.unsubscribe();The constructor stores the producer function; subscribe calls it. The producer returns teardown, and unsubscribe invokes that cleanup at most once. Here, clearTimeout actually cancels the pending work.
Understanding Observables in Practice
A practical RxJS example with teardown
RxJS adds notification safety, subscriptions, and operators. With Node.js and npm installed, install RxJS in your project:
npm install rxjsSave this separate example as observables.mjs. The .mjs extension tells Node.js to treat the file as an ES module.
import { Observable, map, take, finalize } from "rxjs";
const ticks$ = new Observable(subscriber => {
let count = 0;
const intervalId = setInterval(() => {
subscriber.next(++count);
}, 250);
return () => {
clearInterval(intervalId);
console.log("timer cleared");
};
}).pipe(
map(value => value * 10),
take(3),
finalize(() => console.log("finalized"))
);
const subscription = ticks$.subscribe({
next: value => console.log(value),
error: error => console.error("stream failed:", error),
complete: () => console.log("complete")
});
// Uncomment to request cancellation before normal completion:
// setTimeout(() => subscription.unsubscribe(), 100);node observables.mjsNormally, this emits 10, 20, and 30, then completes and runs cleanup. Uncomment the last line to try explicit cancellation instead. Unsubscribing runs teardown but does not call the observer's complete callback.
Reactive programming and operators
Reactive programming means describing how incoming changes flow through transformations. RxJS pipeable operators build a new observable from an existing one without modifying the original observable:
maptransforms each emitted value.take(3)forwards the first three values, then completes its output and unsubscribes from its input.finalizeruns its callback when the subscription ends through completion, error, or explicit unsubscription.
Each timer notification moves through map and take before reaching your observer. That's composition: you describe the processing pipeline rather than writing a separate nested callback for every step.
Error and completion behavior
An error notification is terminal for that subscription, not just another data value. If the source calls error, or an operator throws while processing a value, the failure travels through the pipeline to the observer's error callback; complete does not follow it.
As the Observable API documentation explains, an unhandled stream error is reported asynchronously by default. A try/catch around subscribe won't catch that report. Supply an error callback, and don't assume exceptions thrown inside unrelated asynchronous callbacks automatically become stream errors.
In RxJS, completion and error both dispose the subscription and run registered teardown. Put cleanup needed for every exit path in teardown or finalize—not only in complete.
JavaScript Observables vs Promises
Compare the actual behavior you need, not categorical labels such as “lazy” or “cancellable.” Our Observable vs Promise comparison explores the broader choice.
| Concern | Promise | RxJS observable |
|---|---|---|
| Results | One fulfillment value or rejection reason; it may remain pending. | Zero or more next values, with optional terminal error or complete. |
| Starting work | A constructor executor runs immediately. | Cold sources commonly start work per subscription; hot producers may already be running. |
| Delivery timing | then/catch/finally reactions run asynchronously as microtasks. | Notifications can be synchronous or asynchronous. |
| Cancellation | No built-in promise cancellation protocol. | unsubscribe disposes the subscription; producer teardown determines whether work stops. |
| Sharing | Handlers on the same promise observe its single eventual settlement. | Subscriptions may create independent work or share a producer, depending on construction. |
Eager vs lazy: cold and hot sources
A cold observable creates its producer for each subscription. In the timer example above, each subscriber gets a separate interval and counter. Similarly, subscribing to timer(1000) starts a new timer for that subscription.
A hot source produces independently of a particular subscriber. DOM events are a concrete example: subscribing doesn't restart the user's interactions, and an ordinary event subscription doesn't replay earlier clicks. A newly installed listener and the underlying event producer have different lifecycles.
Likewise, a promise doesn't force every API to start work eagerly: a function returning a promise can defer creating it until you call that function. Don't confuse constructing a promise with defining a promise-returning function.
Timing and schedulers
The Promise constructor's executor runs synchronously. Reactions registered through then, catch, and finally run later as microtasks. Observables have no equivalent universal asynchronous-delivery guarantee: of(1, 2, 3), for example, emits synchronously when subscribed.
A scheduler controls when scheduled work runs. RxJS supports scheduling notification delivery; observeOn(asyncScheduler) makes downstream notifications asynchronous. Changing notification timing does not automatically move CPU-intensive work to another thread.
Cancellation depends on teardown
Promises have no built-in cancellation protocol, although an underlying operation may support cancellation through a separate mechanism such as AbortController. Similarly, unsubscribing from an observable only cancels underlying work if its teardown implements cancellation.
Wrapping an already-running promise doesn't make that promise cancellable. Unsubscription also can't undo a side effect that has already happened. In our RxJS example, cancellation works because teardown calls clearInterval.
Shared results vs shared streams
Multiple handlers attached to one promise share its eventual settlement. That isn't the same abstraction as multicasting a stream of notifications to multiple observers.
RxJS's share() operator, used through pipe, lets subscribers share a source subscription while connected. Cold vs hot describes the producer's relationship to subscriptions; unicast vs multicast describes whether an execution's notifications are shared. These are related ideas, not interchangeable guarantees.
Push vs pull
In a push model, the producer controls when values are delivered; the observer reacts. In a pull model, the consumer requests values—for example, by calling an iterator's next method. Promises also deliver their outcome through a push model; they aren't pull-based streams.
Backpressure is not automatic
Backpressure means allowing downstream demand or capacity to constrain upstream production. The ReactiveX contract treats backpressure as optional; an RxJS subscription doesn't automatically provide demand-based flow control.
RxJS can buffer, throttle, sample, or drop emissions, but those strategies aren't equivalent to asking the producer to slow down. Buffering can accumulate pending data, while dropping intentionally loses values. If every value matters, design an explicit capacity and overload policy rather than assuming subscribe handles it.
When Should You Use an Observable?
- Use a promise when your API needs one eventual result and ordinary async/await keeps the control flow clear.
- Consider an observable when you need multiple values, composition of event streams, or an explicit subscription lifecycle.
- Follow the surrounding API when a framework or library already exposes observables; converting everything to promises can discard stream behavior you need.
The tradeoff isn't just syntax. RxJS adds a dependency, subscription and operator objects, callbacks along the pipeline, and lifecycle decisions you must understand. Buffers can retain data, and forgotten subscriptions can retain resources. Those costs are justified when stream composition simplifies your application—not for every one-off task.
For more selection criteria, see when to use an Observable instead of a Promise.
Common Mistakes
- Assuming subscribe is always asynchronous. Initialize any state your callbacks need before subscribing.
- Forgetting ownership. Decide which component or service owns each long-lived subscription and when it should be disposed.
- Cleaning up only in complete. Explicit unsubscription doesn't invoke complete; use teardown or finalize for cleanup that must always run.
- Expecting an async next callback to pace the source. RxJS doesn't await the promise returned by an async observer callback.
- Accidentally duplicating work. Subscribing twice to a cold source can start two producers; sharing should be an explicit decision.
FAQ
Are Observables built into JavaScript?
ECMAScript does not currently define a built-in Observable type. RxJS provides a library implementation that you can use without a particular UI framework.
How is an RxJS Observable different from a Promise?
A promise represents one eventual fulfillment value or rejection reason, while an observable can deliver zero or more values. RxJS also exposes a subscription lifecycle and stream-composition operators; neither abstraction is universally better.
Are all Observables lazy?
No. Cold observables commonly start independent work when you subscribe, but a hot producer may already be running before a particular observer subscribes. Laziness depends on how the source is constructed.
How does unsubscribe cancel Observable work?
RxJS unsubscribe disposes the subscription and runs its registered teardown. The underlying work stops only if that cleanup cancels it, such as by clearing a timer; explicit unsubscription does not call the observer's complete callback.
Do RxJS Observables provide backpressure?
RxJS observables do not automatically provide demand-based backpressure. Buffering, throttling, sampling, and dropping can manage emissions, but they don't inherently make a producer respect downstream capacity.
Further Reading
- RxJS Observable guide — notifications, execution, and teardown.
- RxJS Subscription guide — disposing subscriptions and grouping cleanup.
- RxJS operators guide — composing transformations with pipe.
- RxJS scheduler guide — controlling notification timing.
- RxJS share API — sharing a source subscription.
- MDN Promise reference — settlement, sharing results, and cancellation boundaries.
- MDN Using promises — reaction timing and microtasks.
- ReactiveX Observable contract — terminal notifications and optional backpressure.
