Key Takeaways
- Use a native Promise for one eventual result; use an RxJS Observable when you need a composable sequence of values.
- A Promise settles at most once. An Observable subscription can receive zero, one, or many values before completion or an error.
- Unsubscribing stops notifications to that subscription. Stopping the underlying work depends on the producer's teardown.
- Don't add RxJS solely for a one-off request unless its operators or your existing architecture justify the dependency.
Neither is universally better. In the Observable vs Promise comparison, start with the behavior you need: one asynchronous result usually fits a Promise; repeated events and coordinated streams often benefit from RxJS. Cancellation, execution timing, and composition matter more than a feature count.
Observable vs Promise: The Core Differences
A Promise represents one eventual fulfillment value or rejection, although it can remain pending forever. An RxJS Observable describes a sequence of notifications that can arrive synchronously or asynchronously. The Promise reference and RxJS Observable guide define these different contracts.
Promise outcomes:
pending -----------------> fulfill(value)
pending -----------------> reject(error)
Observable subscription examples:
next(A) ---- next(B) -----> complete
next(A) -----------------> error
-------------------------> complete (zero values)| Behavior | Native Promise | RxJS Observable |
|---|---|---|
| Results | At most one fulfillment value, or one rejection | Zero, one, or many next notifications |
| Execution | The Promise constructor's executor runs immediately | A cold producer starts separately for each subscription |
| Delivery timing | then handlers run asynchronously | Notifications may be synchronous or asynchronous |
| Composition | Chaining, async/await, and aggregation such as Promise.all | Operators for transforming, timing, combining, and switching streams |
| Cancellation | The operation needs a separate cancellation mechanism | Unsubscription runs registered teardown; the producer must implement cleanup |
Promises Are Simpler and Native
Promises are part of ECMAScript and don't require an additional library. Here's the original one-result example with a consumer and rejection handling:
const myPromise = new Promise((resolve) => {
resolve('data');
});
myPromise
.then((value) => console.log(value))
.catch((error) => console.error(error));The executor—the function passed to new Promise—runs synchronously during construction. The then handler runs later, even though this example fulfills immediately. Calling then returns a new Promise, which enables Promise chaining.
This is the useful meaning of “Promises are eager”: attaching a handler isn't what starts the executor. Calling an operation such as fetch starts that operation; adding another handler to the same Promise doesn't start another request. You can still delay work by putting the operation inside a function and calling it later.
For a one-off request, prefer the API's returned Promise and async/await. Don't wrap an already Promise-based API in another new Promise just to consume its result.
RxJS Observables Offer Stream Composition
RxJS adds operators: functions that transform or coordinate streams. They let you express behavior such as “wait until typing pauses, skip the same query, then listen only to the newest search.” Its scheduling facilities also control when subscriptions or notifications execute.
With Node.js installed, install the package with npm install rxjs, save the following example as observable.mjs, and run node observable.mjs. The official RxJS package documents installation and imports.
import { Observable } from 'rxjs';
const myObservable = new Observable((subscriber) => {
subscriber.next('First value');
subscriber.next('Second value');
subscriber.complete();
});
myObservable.subscribe({
next: (value) => console.log(value),
error: (error) => console.error(error),
complete: () => console.log('Done'),
});This example is deliberately synchronous: both values and completion arrive before subscribe returns. A later unsubscribe cannot cancel notifications that have already happened.
Understand the Observable Lifecycle
next(value)delivers a value.complete()ends the subscription successfully, without delivering a final value itself.error(error)ends the subscription with a failure.
After completion or an error, that subscription receives no further notifications. A stream can also stay open indefinitely without either terminal notification.
The example's producer is cold: each subscription runs the producer independently. Not every Observable starts fresh work; a subscription can instead observe an already-running or shared source. For a gentler introduction, see JavaScript Observables in 5 Minutes.
Are Observables Native to JavaScript?
ECMAScript defines Promises, but it does not define an Observable API. RxJS is a common Observable implementation, not the only library or framework API built around the model.
Observable Cancellation vs Promise Cancellation
Distinguish stopping delivery from stopping work. Neither abstraction makes an arbitrary operation cancellable merely by wrapping it.
Cancel a Fetch Operation With AbortController
A Promise itself has no cancellation method. However, fetch accepts an AbortSignal; calling the associated controller's abort() cancels the fetch operation. MDN's Fetch guide covers both aborting requests and checking HTTP status.
This browser integration fragment assumes your application provides a JSON endpoint at /api/data. It schedules an abort after one second:
// Browser integration fragment: provide a JSON /api/data endpoint.
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), 1000);
async function loadData() {
try {
const response = await fetch('/api/data', {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Request failed', error);
}
} finally {
clearTimeout(deadline);
}
}
loadData();fetch fulfills for HTTP error responses such as 404, so check response.ok. Reading the JSON body is also asynchronous and can fail. Aborting before the response arrives rejects the fetch Promise; aborting while its body is still being read can reject body consumption instead.
Unsubscribe From a Producer That Implements Teardown
In RxJS, teardown is cleanup registered by the producer. This standalone example returns a cleanup function that clears its interval:
import { Observable } from 'rxjs';
const ticks$ = new Observable((subscriber) => {
let count = 0;
const timer = setInterval(() => {
subscriber.next(++count);
}, 250);
return () => {
clearInterval(timer);
console.log('Timer cleared');
};
});
const subscription = ticks$.subscribe({
next: (value) => console.log(value),
error: (error) => console.error(error),
complete: () => console.log('Completed'),
});
setTimeout(() => subscription.unsubscribe(), 600);When unsubscription runs, teardown clears the interval. Unsubscribing does not invoke the observer's complete callback.
By contrast, from(fetch(url)) wraps a Promise from an already-started fetch. Unsubscribing stops delivery to that subscriber, but it does not automatically abort the request. For fetch-aware teardown, RxJS provides fromFetch, which manages an internal AbortController.
A Practical Observable Use Case: Debounced Search
A search field produces a sequence, not one result. You may want to wait for a pause, avoid consecutive duplicate queries, and replace an obsolete request when a newer query is ready.
This browser integration fragment belongs in a bundled module. Pass it an existing input element and a results element, provide a JSON /api/search endpoint, and call the returned cleanup when the view is removed.
// Integration fragment: the caller supplies DOM elements and /api/search.
import {
catchError, debounceTime, distinctUntilChanged,
fromEvent, map, of, switchMap,
} from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
export function mountSearch(input, output) {
const subscription = fromEvent(input, 'input').pipe(
map(() => input.value.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (!query) return of([]);
return fromFetch(`/api/search?q=${encodeURIComponent(query)}`, {
selector: async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
}).pipe(
catchError((error) => {
console.error('Search failed', error);
return of({ error: 'Search failed' });
}),
);
}),
).subscribe({
next: (result) => {
output.textContent = JSON.stringify(result, null, 2);
},
error: (error) => console.error('Search stream failed', error),
});
return () => subscription.unsubscribe();
}debounceTime(300)waits for 300 milliseconds without another input emission.distinctUntilChanged()skips a query equal to the previously emitted query.switchMapunsubscribes from the previous inner subscription when the next query reaches it.- The
fromFetchselector keeps response-body consumption inside the cancellable subscription. - Handling request errors inside
switchMaplets later input events continue producing searches.
Because debouncing happens first, an older request can still finish during the quiet-period wait. Cancellation happens when the next distinct, debounced query reaches switchMap, not immediately on every keystroke.
You can implement this with Promises, timers, and an AbortController. RxJS earns its place when composing these policies is clearer than maintaining the coordination yourself.
Converting Between Promises and Observables
Convert a Promise to an Observable
Use RxJS from to expose a Promise's fulfillment as a value followed by completion, or its rejection as an error:
import { from } from 'rxjs';
const promise = Promise.resolve('data');
from(promise).subscribe({
next: (value) => console.log(value),
error: (error) => console.error(error),
complete: () => console.log('Done'),
});This doesn't restart the operation or add cancellation. To create the Promise at subscription time, use defer with a factory that creates a new Promise. Deferring creation changes when work starts, not whether the underlying API supports cancellation.
Convert an Observable to a Promise
import { firstValueFrom, lastValueFrom, of } from 'rxjs';
async function main() {
const values$ = of('First value', 'Second value');
console.log(await firstValueFrom(values$)); // First value
console.log(await lastValueFrom(values$)); // Second value
}
main().catch((error) => console.error(error));firstValueFromresolves on the first value and closes its subscription; it doesn't need the source to complete.lastValueFromwaits for completion and resolves with the last value.- Both reject with
EmptyErrorif the source completes without emitting, unless you supply adefaultValue. Source errors reject the returned Promise.
firstValueFrom can remain pending if the source neither emits nor completes. lastValueFrom can remain pending if the source never completes. Before awaiting a long-lived stream, define a timeout or termination policy.
Choosing Between the Two
Choose a Promise by default for a single result. Loading configuration, fetching one resource, or sequencing independent asynchronous steps rarely justifies adding a stream library. Several concurrent requests aren't automatically a stream problem either: Promise.all aggregates their fulfillment values into one result.
Choose an Observable when stream behavior drives the design. Repeated events, coordinated sources, timing rules, and switching between ongoing operations are good reasons to consider RxJS. Our guide to when to use an Observable explores that decision further.
Observables can represent one-result asynchronous work, but they aren't simply “Promises plus more.” You're adopting different lifecycle semantics, a dependency, and an operator vocabulary. If the project already uses RxJS, keeping a stream intact may be clearer than repeatedly converting it at intermediate layers.
Common Mistakes
- Assuming subscriptions share work. Repeated subscriptions to a cold producer can repeat requests. Decide deliberately whether work should be independent, shared, or cached.
- Using latest-only behavior for every operation. Don't choose
switchMapfor writes that must all finish. Don't treat client cancellation as confirmation that a server-side change was rolled back. - Testing only successful emissions. Test empty completion, errors, repeated subscriptions, and cleanup while work is pending—not just the values you expect to log.
FAQ
What is the main difference between a Promise and an Observable?
A Promise settles at most once, with one fulfillment value or rejection. An RxJS Observable subscription can receive zero, one, or many values, then complete or error; it can also remain open indefinitely.
Does unsubscribing from an Observable cancel the underlying request?
Only if the producer connects teardown to request cancellation. Wrapping an existing fetch Promise with from does not automatically abort it; native fetch supports AbortController, and RxJS fromFetch integrates abort handling with subscription teardown.
Can I use Promises and Observables together?
Yes. Use from to consume a Promise through RxJS, firstValueFrom to await the first Observable value, or lastValueFrom to await the last value after completion. Choose the conversion boundary deliberately so you don't accidentally discard stream behavior.
How do I convert a Promise to an Observable?
Import from from rxjs and pass it the Promise. This preserves the existing operation's execution timing and does not add cancellation; use defer with a factory if you need to create a new Promise per subscription.
Are Promises or Observables faster by default?
Neither abstraction guarantees better performance. Benchmark your actual producer, operators, subscription count, and cleanup behavior; for network-backed code, measure end-to-end latency and duplicate requests rather than construction time alone.
Further Reading
- MDN: Promise — settlement, chaining, and concurrent operations.
- RxJS: Observables — execution, notifications, and disposal.
- MDN: Using Fetch — HTTP errors, body consumption, and cancellation.
- RxJS: fromFetch — abort-aware fetch subscriptions and selectors.
- RxJS: from — adapting Promises and other inputs.
- RxJS: firstValueFrom — first-emission conversion and empty-stream handling.
- RxJS: lastValueFrom — completion-based conversion and pending-Promise warnings.
- MDN: Web Observable API — the separate browser API and compatibility considerations.
