Skip to content
Angular ng rxjs 4 min read

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.

AspectPromiseObservable
ExecutionEager — runs the moment it’s createdLazy — runs only when subscribed
Values emittedExactly one (resolve) or one rejectionZero, one, or many over time
CancellationNot cancellableCancellable via unsubscribe()
OperatorsNone built in (chain with .then)Rich pipeline (map, filter, switchMap, …)
Re-executionResult memoized; runs onceRe-runs the producer per subscriber (cold)
Sync emissionAlways async (microtask)Can emit synchronously or async
Native supportBuilt into JSProvided 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 HttpClient request each trigger a separate network call. Use shareReplay() or a Subject when 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 to undefined on empty streams; firstValueFrom/lastValueFrom throw an EmptyError instead, 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/lastValueFrom over the deprecated toPromise() when bridging to async/await.
  • Let operators like switchMap cancel stale inner work instead of hand-rolling AbortController logic.
  • Always tear down long-lived subscriptions (or use takeUntilDestroyed / toSignal) to avoid leaks.
Last updated June 14, 2026
Was this helpful?