Key Takeaways
- Use a Promise for one eventual result; use an Observable when you need ongoing values or stream composition.
- Async/await is established syntax for Promise-based control flow, not a replacement for Promises.
- Native Promises need no RxJS dependency. Add RxJS when its operators and subscription model simplify your application.
- Cancellation depends on the producer: fetch supports AbortSignal, while Observable unsubscription stops external work only when teardown supports it.
Use a Promise when your caller needs one eventual result, such as a parsed HTTP response or the contents of a configuration file. Choose an Observable when your caller needs a sequence of values, such as user input or WebSocket messages, especially when you need to combine or coordinate those values. Cancellation alone isn't a reason to switch abstractions.
This guide focuses on choosing Promises in application code. For the broader comparison, see the differences between Observables and Promises.
When you want one eventual result
A Promise can remain pending or settle once: fulfilled with a value, or rejected with a reason. That value can be an object, an array, or another kind of result; “single result” doesn't mean a single primitive value. An RxJS Observable can deliver zero or more next notifications, then optionally terminate with either complete or error. It can also remain active indefinitely.
Example timelines; time moves right
Promise: pending ---------------- fulfilled(value)
Observable: subscribe -- next(a) -- next(b) -- complete
Observable: subscribe -- next(a) ------------ error
A Promise may reject instead of fulfilling.
An Observable may emit nothing or never terminate.Think about the interface your caller needs:
- Load a profile: a Promise is a natural fit when the caller wants one parsed response.
- Read a configuration file: Node.js readFile from node:fs/promises returns a Promise that fulfills with the file contents.
- React to changing input: an Observable is useful when each edit becomes another value to process.
- Consume WebSocket messages: an Observable can represent messages over the connection's lifetime.
HTTP isn't inherently one-shot at every layer. fetch() returns a Promise for one Response, but that response can expose a streaming body. Calling response.json() reads the body and returns another Promise for the parsed result. You don't need RxJS merely because an HTTP body arrives incrementally.
When to use Promise vs Observable: a decision table
| Your requirement | Starting choice | What to check |
|---|---|---|
| One eventual value or failure | Promise with async/await | Does the API already return a Promise? |
| Repeated input or messages | Observable when composition helps | Who owns the subscription and its cleanup? |
| Cancellation | Either abstraction | Does the underlying API or producer actually support stopping work? |
| Debouncing and latest-result coordination | Consider RxJS | Do operators simplify more code than the dependency introduces? |
| Incremental response-body processing | Consider the platform stream API first | Do you need byte-stream processing or application-level event composition? |
When you want to use async/await
Async/await lets you express Promise-based dependencies in normal-looking control flow. An async function always returns a Promise. Inside it, await suspends that function until the awaited Promise settles; fulfillment supplies a value, and rejection throws at the await expression. Waiting doesn't block the main thread.
Use this style when the workflow reads naturally as “load the data, validate it, then use it,” with try/catch handling failures. It doesn't change the Promise's single-settlement behavior, make existing work lazy, or introduce cancellation. See the JavaScript async/await example for a focused walkthrough.
Execution timing: eager Promises and cold Observables
The function passed to new Promise(executor) runs immediately during construction. Attaching then() or awaiting the Promise isn't what starts that executor. To defer starting a Promise-returning operation, keep it behind a function and call that function when you're ready.
A cold Observable creates a producer for each subscription. A hot Observable uses a producer created outside the subscription, so that producer may already be active. Don't assume every Observable is lazy or that every subscriber gets a separate external resource.
When you don't want a third-party dependency
Promise is a built-in JavaScript abstraction; you don't need RxJS to use it. If platform APIs and async/await already express your workflow clearly, keep that simpler boundary. Our JavaScript Promises tutorial covers the underlying model.
Dependency avoidance is a useful tie-breaker, not the main selection rule. If your application already uses RxJS and the result belongs inside an existing Observable pipeline, keeping it there can be clearer than converting back and forth.
When you need cancellation, check the operation's API
Promises themselves have no standard cancellation operation. That doesn't mean the work producing a Promise must continue: APIs such as fetch accept an AbortSignal. Calling the associated controller's abort() requests cancellation of that operation. Aborting an ongoing fetch without a custom reason rejects with an AbortError.
This browser integration fragment assumes your application provides a JSON endpoint at /api/profile. The caller owns the controller and passes its signal through the helper to fetch. The five-second timer is an example policy, not a fetch default.
// Browser integration fragment: /api/profile must return JSON.
async function loadProfile(signal) {
const response = await fetch("/api/profile", { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
async function run() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const profile = await loadProfile(controller.signal);
console.log(profile);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
console.log("Request canceled.");
} else {
console.error("Profile load failed:", error);
}
} finally {
clearTimeout(timeoutId);
}
}
void run();The status check matters: fetch doesn't reject merely because the server returns an HTTP error status. Cancellation can also interrupt response-body consumption after the fetch Promise has fulfilled.
For a user-driven operation, connect the controller to the caller's cancellation or lifecycle boundary instead of relying only on a timer. Keep cancellation ownership visible rather than hiding an inaccessible controller inside your data-access helper.
When an Observable earns its RxJS dependency
RxJS becomes useful when the problem is the relationship between events, not simply waiting for one result. For example, debounceTime waits for a quiet interval before emitting the latest pending value. switchMap switches observation to the latest inner source, unsubscribing from the previous one. Whether that also stops an earlier request depends on that source's teardown.
That combination is useful for search input: wait until typing pauses, start the relevant lookup, and stop observing obsolete lookups. For more stream-oriented cases, see when to use an Observable.
Make subscription teardown explicit
Teardown is the cleanup performed when a subscription ends. Calling unsubscribe() disposes that subscription, but external resources stop only through the cleanup implemented by the producer. Unsubscription is not a complete notification to your observer.
This browser module fragment requires RxJS to be installed and resolved by your build setup. It creates an input and a stop button, then logs debounced input values; it doesn't make network requests.
// Browser module fragment; run after document.body exists.
import { Observable, debounceTime } from "rxjs";
const input = document.createElement("input");
input.setAttribute("aria-label", "Search query");
const stop = document.createElement("button");
stop.type = "button";
stop.textContent = "Stop observing";
document.body.append(input, stop);
const text$ = new Observable((subscriber) => {
const onInput = () => subscriber.next(input.value);
input.addEventListener("input", onInput);
// This teardown is what removes the external resource.
return () => input.removeEventListener("input", onInput);
});
const subscription = text$.pipe(debounceTime(250)).subscribe({
next: (query) => console.log("Query:", query),
error: (error) => console.error("Input stream failed:", error),
});
stop.addEventListener("click", () => subscription.unsubscribe(), {
once: true,
});Here, unsubscription removes the input listener because the producer explicitly returns that cleanup function. A request-producing Observable needs corresponding request cleanup; wrapping work in an Observable isn't enough. In a component, also arrange disposal when the component is destroyed.
Convert to a Promise at a deliberate one-result boundary
In recent RxJS versions, firstValueFrom(source$) subscribes, fulfills with the first emitted value, and closes its subscription. lastValueFrom(source$) waits for completion and fulfills with the last emitted value. Choose the behavior your caller actually needs rather than mechanically converting every Observable.
Both reject on an observed source error. Without a configured default value, both also reject with EmptyError if the source completes without emitting. The first-value conversion can remain pending if nothing emits or terminates; the last-value conversion can remain pending on a source that never terminates. Establish an appropriate timeout or termination boundary before converting.
Common Mistakes
- Assuming a Promise timeout stops the operation. Settling a wrapper or winning a race isn't cancellation; arrange cancellation separately through the operation's API.
- Assuming conversion changes execution ownership. In
from(fetch(...)), fetch is called beforefromreceives its Promise. That wrapper doesn't make the request lazy or wire up abort behavior. - Subscribing twice without checking the source. A cold Observable creates a producer per subscription, so repeated subscriptions can duplicate work. Decide whether consumers should share one producer or own separate executions.
FAQ
When should I use a Promise instead of an Observable?
Use a Promise when the caller needs one eventual result or failure and doesn't need an ongoing stream pipeline. Async/await is a straightforward way to consume that result.
Does async/await replace Promises?
No. Async functions return Promises, and await suspends the surrounding async function until the awaited Promise settles. It doesn't add cancellation or make existing work lazy.
Can a JavaScript Promise be canceled?
A Promise has no standard cancellation method, but the operation producing it may support cancellation. For example, pass an AbortSignal to fetch and call the controller's abort() method while the operation is ongoing.
Does unsubscribing from an Observable cancel the underlying request?
Only if the producer's teardown supports that behavior. Unsubscribing disposes the subscription, but it doesn't automatically stop unrelated or non-cooperative external work.
Should I use RxJS for a single HTTP request?
Usually not solely for that request: fetch and async/await can express a one-result workflow without RxJS. Keeping an Observable is reasonable when the request already belongs to an RxJS pipeline or an Observable-based framework API.
Further Reading
- MDN: Promise — settlement, chaining, and cancellation boundaries.
- MDN: async functions — Promise return values and async control flow.
- MDN: Using Fetch — status handling, cancellation, and streaming bodies.
- MDN: AbortSignal — cancellation signals and implementing cooperative cancellation.
- RxJS: Observable guide — subscriptions, notifications, and producer teardown.
- RxJS: Importing instructions — package imports for your installed release.
- RxJS: firstValueFrom — converting the first emitted value to a Promise.
- RxJS: lastValueFrom — completion-dependent conversion and its caveats.
