Key Takeaways
- Choose an Observable when you need a sequence of values over time, not just one eventual result.
- Keep Observables when RxJS operators make event handling and asynchronous composition clearer, especially with Angular APIs.
- Unsubscribing stops notifications to that subscription; stopping the underlying work requires producer teardown. Promise-based requests can also support cancellation through AbortController.
- Prefer a Promise and async/await for a single result when streaming and RxJS composition add no value. Neither abstraction is inherently better.
Use an Observable when your operation is naturally a sequence: form changes, WebSocket messages, polling results, or requests driven by changing input. Use a Promise when you simply need to await one result. Cancellation matters too, but it depends on the underlying operation—not just which abstraction you choose.
This guide uses RxJS Observables. If your operation is a straightforward one-shot task, start with our companion guide on when to use a Promise instead.
Observable vs Promise: choose by the operation
A Promise settles at most once: it fulfills with a value or rejects with a reason. An Observable can deliver zero, one, or many values through next, followed by completion or an error; it can also remain active without either terminal notification. These are different contracts, not competing quality levels.
| Question | Promise | RxJS Observable |
|---|---|---|
| How many results? | One fulfillment value or rejection reason | Zero, one, or many next values |
| When does work start? | Usually when the operation is called or the Promise is created | For a cold source, when you subscribe |
| What happens with multiple consumers? | Handlers observe the same settlement | Cold sources can execute separately for each subscription |
| How do you stop work? | Use cancellation supported by the underlying API | Unsubscribe; producer teardown determines what actually stops |
| Best starting point? | A single result with ordinary async/await control flow | Events, changing inputs, and composed sequences |
The execution and cancellation distinctions above are documented in the Promise constructor, RxJS Observable, and Subscription references.
Promise: pending --------> fulfilled(value) OR rejected(reason)
Observable: next(a) -- next(b) -- next(c) --> complete OR error
zero or more values; a terminal notification is not guaranteedWhen you want to consume multiple values
The strongest reason to choose an Observable is that later values matter independently of earlier ones. A Promise can fulfill with an array, but that is still one result containing a collection—not repeated delivery of future updates.
A finite sequence with transformation and cleanup
This example requires the rxjs package in an ESM-capable project. It emits five numbers over time, keeps the odd numbers with filter, and transforms them with map. The returned function is teardown: RxJS runs it when the subscription ends.
import { Observable, filter, map } from 'rxjs';
const numbers$ = new Observable((subscriber) => {
let value = 0;
const timer = setInterval(() => {
subscriber.next(++value);
if (value === 5) subscriber.complete();
}, 250);
return () => clearInterval(timer);
});
const subscription = numbers$.pipe(
filter((value) => value % 2 === 1),
map((value) => value * 10),
).subscribe({
next: (value) => console.log(value),
error: (error) => console.error(error),
complete: () => console.log('Done'),
});
// Logs 10, 30, 50, then Done.
// Call subscription.unsubscribe() to stop early instead.Completion and explicit unsubscription both release this timer. Explicitly unsubscribing does not call the observer's complete handler.
Ongoing streams need a lifetime policy
WebSocket messages, server-sent events, and repeated polling results are ongoing sequences rather than batches that necessarily finish. Decide who owns the subscription and when it should end. Reactive programming here means describing how your application reacts as each value arrives—not collecting everything before processing it.
Batch processing alone isn't a reason to add RxJS. If you already have a collection and a few asynchronous steps, a loop and Promises may be easier to maintain. For a smaller introduction to subscriptions and producers, see JavaScript Observables in 5 Minutes.
Understand laziness before subscribing
Common cold RxJS sources defer execution until subscription. A cold source starts its producer separately for each subscriber; Angular's HttpClient is a practical example. Subscribing twice to the same HTTP Observable sends two requests, not one shared request.
By contrast, the executor passed to new Promise runs synchronously during construction. A function that creates a Promise can defer that construction until you call it, so “Promises are eager” describes a common execution pattern, not an unavoidable application design.
Not every Observable owns a fresh producer: a shared or hot source can deliver events from work that already exists. Likewise, from(existingPromise) adapts a Promise without restarting or delaying its operation. Use defer(() => promiseReturningFunction()) when you need to create that operation per subscription; deferring it does not automatically add cancellation.
When you're already using RxJS and its operators
Angular exposes Observable-based APIs through HttpClient and reactive forms' valueChanges. Keeping those values in RxJS lets you compose them without repeatedly crossing between subscriptions and Promises.
Don't assume that makes RxJS the default elsewhere. React provides built-in Hooks, and Node.js provides native streams, including Promise-based stream utilities. RxJS is an optional dependency in those projects; add it when sequence composition earns its learning and maintenance cost.
Choose operators for their behavior, not their reputation
maptransforms each value;filterkeeps values that satisfy a condition.switchMapunsubscribes from the previous inner subscription when a new source value arrives. It's useful when only the latest search or selection matters.mergeMap(project, 4)allows up to four active inner subscriptions. Results can arrive out of input order.catchErrorreplaces a failed sequence with another Observable or propagates a replacement error. Its position determines which part of your workflow recovers.
An unhandled inner error can terminate the composed subscription. For repeated searches, handle request errors inside switchMap if later input should still work. Don't use latest-only cancellation for writes that all need to finish, and don't build an operator chain where a short async function would communicate the intent better.
If stopping asynchronous work is crucial
Unsubscription closes a subscription and runs its registered teardown. It stops a timer only if teardown clears it, and stops a request only if teardown connects to request cancellation. Without that cooperation, you may stop observing results while the operation continues.
An Observable with aborting teardown
These browser examples assume a same-origin /api/status endpoint returning text. This wrapper explicitly connects teardown to AbortController; cancellation is implemented here, not supplied automatically by the Observable type. It also checks HTTP status because fetch does not reject solely because the server returns an error status.
import { Observable } from 'rxjs';
function fetchText$(url) {
return new Observable((subscriber) => {
const controller = new AbortController();
void (async () => {
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
subscriber.next(text);
subscriber.complete();
} catch (error) {
subscriber.error(error);
}
})();
return () => controller.abort();
});
}
const subscription = fetchText$('/api/status').subscribe({
next: (text) => console.log(text),
error: (error) => console.error(error),
});
// Simulate the consumer disappearing immediately.
subscription.unsubscribe();A Promise-based request can also be aborted
A Promise has no built-in cancellation method, but fetch accepts an AbortSignal. Aborting an unsettled fetch rejects its Promise; it does not “cancel the Promise” as a separate object. An already fulfilled Promise stays fulfilled, although aborting can still interrupt consumption of the response body.
const controller = new AbortController();
const request = fetch('/api/status', { signal: controller.signal });
// Simulate the consumer disappearing immediately.
controller.abort();
try {
const response = await request;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.text());
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error(error);
}
}When to use Observable in Angular
A search box combines an ongoing sequence of form values with one-shot HTTP requests. That is a good Observable use case even though each individual request normally emits its response and completes. Angular's async pipe manages the view's subscription and unsubscribes when the component is destroyed.
Compose form changes and HttpClient with the async pipe
Use this as src/main.ts in a standalone Angular project whose page contains <app-root></app-root>. Supply a backend where GET /api/users?q=... returns an array of users matching the interface. The example includes HTTP providers, template imports, and request-error handling.
import { AsyncPipe } from '@angular/common';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { bootstrapApplication } from '@angular/platform-browser';
import { catchError, debounceTime, map, of, switchMap } from 'rxjs';
interface User { id: number; name: string; }
interface SearchState { users: User[]; error: string | null; }
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe, ReactiveFormsModule],
template: `
<label>Search users <input [formControl]="query"></label>
@if (state$ | async; as state) {
@if (state.error) { <p role="alert">{{ state.error }}</p> }
<ul>
@for (user of state.users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
} @else {
<p>Type a name to search.</p>
}
`,
})
class App {
private readonly http = inject(HttpClient);
readonly query = new FormControl('', { nonNullable: true });
readonly state$ = this.query.valueChanges.pipe(
debounceTime(300),
map((value) => value.trim()),
switchMap((q) => q
? this.http.get<User[]>('/api/users', { params: { q } }).pipe(
map((users): SearchState => ({ users, error: null })),
catchError((error) => {
console.error('User search failed', error);
return of<SearchState>({
users: [], error: 'Search failed. Try again.',
});
}),
)
: of<SearchState>({ users: [], error: null }),
),
);
}
bootstrapApplication(App, {
providers: [provideHttpClient()],
}).catch((error) => console.error('Bootstrap failed', error));debounceTime(300) waits for a 300-millisecond pause in input. When a new debounced query arrives, switchMap unsubscribes from the previous request; Angular aborts an in-progress HTTP request on unsubscription. Catching errors inside that request keeps subsequent searches available.
The template uses one async subscription. Avoid separately subscribing to the same cold request just to read its result elsewhere. Also remember that get<User[]> describes the expected TypeScript type; it does not validate the server's JSON at runtime. See our Angular HttpClient tutorial for the request fundamentals.
Observables and Signals solve different problems
An Angular Signal exposes a current value you can read synchronously. An Observable describes notifications over time. Prefer Signals for current UI state and derived values, and RxJS when timing, request switching, or event composition is the important part.
toSignal bridges an Observable into signal-based state and subscribes immediately; reuse its result instead of calling it repeatedly for the same source. For imperative subscriptions, takeUntilDestroyed provides lifecycle-aware cleanup. Outside an injection context, pass it a DestroyRef.
Convert to a Promise at a single-result boundary
Use firstValueFrom when the caller needs one value with await: it subscribes, resolves on the first emission, and closes the subscription. Use lastValueFrom only when completion matters and the source will complete; it resolves with the final emitted value after completion.
// Angular helper: call with an injected HttpClient.
import { HttpClient } from '@angular/common/http';
import { firstValueFrom, timeout } from 'rxjs';
export async function printStatus(http: HttpClient): Promise<void> {
try {
const status = await firstValueFrom(
http.get('/api/status', { responseType: 'text' }).pipe(
timeout({ first: 5000 }),
),
);
console.log(status);
} catch (error) {
console.error('Status request failed', error);
}
}The timeout bounds the wait for the first emission to five seconds. Without a suitable bound, firstValueFrom can remain pending if the source neither emits nor completes, and lastValueFrom can remain pending if it never completes. Both reject with EmptyError on empty completion unless you supply a default value, and both reject on source errors.
Common Mistakes
- Adding RxJS for one request: choose it for useful composition, not because an operation is asynchronous.
- Assuming every subscription shares work: cold sources can execute again for each subscriber.
- Nesting subscriptions: prefer one composed pipeline when the operations share cancellation and error-handling rules.
- Forgetting ownership: decide whether the component, service, or another consumer controls the subscription's lifetime.
- Equating unsubscribe with guaranteed cancellation: inspect the producer's teardown behavior.
FAQ
Can an Observable emit only one value?
Yes. An Observable can emit zero, one, or many values. Angular HttpClient commonly represents a single response as an Observable that then completes.
Does unsubscribing from an Observable cancel the underlying request?
Only when the producer connects teardown to request cancellation. Angular HttpClient does this for in-progress requests, but wrapping an existing Promise with from does not automatically make its operation cancellable.
Can a Promise be canceled with AbortController?
AbortController cancels a supporting operation, not the Promise object itself. For fetch, aborting an unsettled request rejects its Promise; an already fulfilled Promise does not change state.
When should I use an Observable instead of a Promise in Angular?
Keep an Observable when composing form changes, switching requests, or consuming multiple values. For one result needed by imperative async/await code, firstValueFrom can be a simpler boundary.
How do I convert an Observable to a Promise in RxJS?
Use firstValueFrom for the first emitted value or lastValueFrom for the final value after completion. Ensure the source will emit or complete as required; lastValueFrom stays pending if the source never completes.
Further Reading
- RxJS Observable guide — execution, notifications, subscriptions, and disposal.
- MDN Promise reference — settlement and Promise behavior.
- MDN Fetch guide — status handling and aborting requests.
- Angular HTTP requests — cold requests, cancellation, and response typing.
- Angular AsyncPipe reference — template subscriptions and cleanup.
- Angular Signals and RxJS interoperability — bridging sequences into current state.
- RxJS firstValueFrom reference — awaiting the first emission.
- RxJS lastValueFrom reference — awaiting completion and the final value.
