Observables vs Promises
Both observables and promises model asynchronous work, but they make fundamentally different trade-offs. Angular leans heavily on RxJS observables for HTTP, forms, routing, and component communication, so understanding how they diverge from native promises is essential to writing idiomatic Angular code. The headline differences come down to three axes: when work starts (lazy vs eager), how many values you can receive (one vs many), and whether you can stop work in flight (cancellable vs not).
A side-by-side overview
The quickest way to internalize the differences is to see them in one table.
| Aspect | Promise | Observable |
|---|---|---|
| Execution | Eager — runs the moment it’s created | Lazy — runs only when subscribed |
| Values emitted | Exactly one (resolve) or one rejection | Zero, one, or many over time |
| Cancellation | Not cancellable | Cancellable via unsubscribe() |
| Operators | None built in (chain with .then) | Rich pipeline (map, filter, switchMap, …) |
| Re-execution | Result memoized; runs once | Re-runs the producer per subscriber (cold) |
| Sync emission | Always async (microtask) | Can emit synchronously or async |
| Native support | Built into JS | Provided by RxJS |
Lazy vs eager execution
A promise begins executing as soon as it is constructed. The executor function runs immediately, regardless of whether anyone has attached a .then().
// The fetch fires right away, even with no `.then`.
const promise = fetch('/api/users');
console.log('promise created');
An observable, by contrast, is just a recipe. Nothing happens until you subscribe — and each subscription runs the producer again.
import { Observable } from 'rxjs';
const obs$ = new Observable<number>((subscriber) => {
console.log('producer running');
subscriber.next(1);
subscriber.complete();
});
console.log('observable created'); // producer has NOT run yet
obs$.subscribe((v) => console.log('A:', v));
obs$.subscribe((v) => console.log('B:', v));
Output:
observable created
producer running
A: 1
producer running
B: 1
Because observables are lazy and (by default) cold, two subscribers to the same
HttpClientrequest each trigger a separate network call. UseshareReplay()or aSubjectwhen you need to multicast a single execution.
Single value vs a stream of values
A promise settles exactly once. After it resolves or rejects, it is done forever. This fits one-shot operations like a single HTTP response.
async function loadUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return res.json(); // one value, then settled
}
Observables model a stream — they can emit any number of values over time, which is why they map naturally to events, WebSocket messages, form value changes, and route params.
import { interval, take } from 'rxjs';
interval(1000).pipe(take(3)).subscribe((n) => console.log('tick', n));
Output:
tick 0
tick 1
tick 2
In a component, this multi-value nature is what lets you react to live input. Here a search box drives a stream of queries with modern signal interop:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<input [formControl]="query" placeholder="Search users" />
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient);
query = new FormControl('', { nonNullable: true });
// valueChanges is an Observable emitting many times as the user types.
results = toSignal(
this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.http.get<User[]>(`/api/users?q=${q}`)),
),
{ initialValue: [] as User[] },
);
}
interface User { id: number; name: string; }
The valueChanges observable emits on every keystroke. A promise simply cannot express this — it would resolve once and stop.
Cancellation
This is the difference that bites teams most often. A promise has no cancel button. Once fetch starts, awaiting it to completion is the only built-in path; aborting requires a separate AbortController bolted on.
Observables make cancellation first-class. Calling unsubscribe() tears down the producer — and for HttpClient, that actually cancels the underlying HTTP request.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
const http = inject(HttpClient);
const sub = http.get('/api/slow-report').subscribe((data) => console.log(data));
// User navigates away — abort the in-flight request immediately.
sub.unsubscribe();
Operators like switchMap build on this: when a new value arrives, the previous inner subscription is unsubscribed automatically, cancelling stale requests. That is why the search example above never shows results from an outdated query.
Converting between the two
Interop is straightforward when you need it. Use firstValueFrom / lastValueFrom to await an observable, and from to lift a promise into a stream.
import { from, firstValueFrom, of } from 'rxjs';
// Observable -> Promise (resolves on first emission, then unsubscribes)
const user = await firstValueFrom(this.http.get<User>('/api/users/1'));
// Promise -> Observable
from(fetch('/api/ping')).subscribe((res) => console.log(res.status));
Avoid the deprecated
.toPromise(). It silently resolves toundefinedon empty streams;firstValueFrom/lastValueFromthrow anEmptyErrorinstead, which is safer and explicit.
Best Practices
- Reach for observables when work emits multiple values, may need cancellation, or benefits from operators — that covers most Angular async (HTTP, forms, router, events).
- Use promises (or
async/await) for genuinely one-shot, fire-and-forget operations where streaming and cancellation add no value. - Remember observables are lazy: if “nothing happened,” confirm you actually subscribed.
- Treat HTTP observables as cold and
shareReplay()when several consumers must share one network call. - Prefer
firstValueFrom/lastValueFromover the deprecatedtoPromise()when bridging to async/await. - Let operators like
switchMapcancel stale inner work instead of hand-rollingAbortControllerlogic. - Always tear down long-lived subscriptions (or use
takeUntilDestroyed/toSignal) to avoid leaks.