RxJS and Observables
Observables vs Promises, HttpClient, subscribing, and consuming streams with the async pipe.
What is an Observable?
A Promise resolves exactly once — you await it, and you get a single value (or a single error) back. An Observable, the core primitive of the RxJS library that Angular is built on, represents a stream of values over time — zero, one, several, or infinitely many — and, critically, it's cancellable: you can stop listening at any point, and the underlying work can be cleaned up.
| Promise | Observable | |
|---|---|---|
| Number of values | Exactly one (or one rejection) | Zero, one, many, or infinite, over time |
| Starts executing | Immediately when created | Only when .subscribe() is called ("lazy") |
| Cancellable | No | Yes — .unsubscribe() |
| Built-in operators | No (only .then/.catch) |
Many (map, filter, debounceTime, switchMap, ...) |
A good mental model: a Promise is a single delivered package; an Observable is a subscription to a stream that can keep delivering packages until you cancel it — think a WebSocket message stream, a sequence of user click events, or periodic polling, none of which fit naturally into "resolve exactly once."
import { of } from "rxjs";
const numbers$ = of(1, 2, 3); // an Observable that emits 1, then 2, then 3, then completes
numbers$.subscribe((value) => console.log(value));
// 1
// 2
// 3
The trailing $ in numbers$ is a widely used (though not enforced by the compiler) naming convention throughout Angular/RxJS code, marking a variable as an Observable at a glance.
HttpClient returns Observables
Angular's built-in HttpClient service returns an Observable for every request — not a Promise, unlike the native browser fetch:
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
export interface Product {
id: number;
name: string;
price: number;
}
@Injectable({ providedIn: "root" })
export class ProductService {
constructor(private http: HttpClient) {}
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>("/api/products");
}
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`/api/products/${id}`);
}
}
Nothing is actually sent over the network until something calls .subscribe() on the returned Observable — HttpClient's Observables are lazy, unlike a Promise from fetch(), which starts the request the instant it's created.
Subscribing manually
import { Component, inject, OnInit, OnDestroy } from "@angular/core";
import { Subscription } from "rxjs";
import { ProductService, Product } from "./product.service";
@Component({
selector: "app-product-list",
standalone: true,
template: `
<ul>
<li *ngFor="let product of products">{{ product.name }}</li>
</ul>
`,
})
export class ProductListComponent implements OnInit, OnDestroy {
private productService = inject(ProductService);
products: Product[] = [];
private subscription?: Subscription;
ngOnInit() {
this.subscription = this.productService.getProducts().subscribe({
next: (data) => (this.products = data),
error: (err) => console.error("Failed to load products:", err),
});
}
ngOnDestroy() {
this.subscription?.unsubscribe(); // prevent a memory leak if the component is destroyed first
}
}
This works, but notice the ceremony: a stored Subscription, an ngOnDestroy lifecycle hook, and manual cleanup — all just to avoid leaking a subscription if the component gets destroyed before the Observable completes (e.g., the user navigates away while a request is still in flight). Forgetting the ngOnDestroy cleanup here is one of the most common sources of memory leaks and stale-data bugs in real Angular applications.
The async pipe: the idiomatic alternative
Angular templates have a built-in async pipe that subscribes to an Observable automatically, unwraps its latest emitted value for display, and — critically — unsubscribes automatically when the component is destroyed. It eliminates the manual subscribe/unsubscribe dance above entirely:
import { Component, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ProductService } from "./product.service";
@Component({
selector: "app-product-list",
standalone: true,
imports: [CommonModule],
template: `
<ul>
<li *ngFor="let product of products$ | async">
{{ product.name }} — \${{ product.price }}
</li>
</ul>
`,
})
export class ProductListComponent {
private productService = inject(ProductService);
products$ = this.productService.getProducts();
}
products$ | async does three things at once: subscribes when the template is rendered, re-renders the list every time a new value is emitted, and unsubscribes automatically when the component is destroyed. There's no ngOnInit, no stored Subscription, no ngOnDestroy — the component class stays a thin, declarative description of "here's the Observable," and the template handles the rest. This is why the async pipe, not manual subscription, is considered idiomatic Angular for consuming Observables in a template.
A brief note on operators
RxJS's real power is its library of operators for transforming a stream, piped together:
import { map, filter } from "rxjs/operators";
this.productService.getProducts().pipe(
filter((products) => products.length > 0),
map((products) => products.map((p) => p.name))
);
.pipe() chains operators together, each receiving the previous one's output — conceptually similar to chaining array methods (.filter().map()), but operating on values arriving over time rather than a fixed in-memory array all at once. RxJS operators are a deep topic on their own; the key takeaway for now is that Observables aren't just "a Promise that can fire more than once" — they come with a rich, composable transformation toolkit built specifically for streams of values over time.
Common mistakes
- Treating an Observable like a Promise and expecting it to have already run just because it was created — nothing happens until something subscribes (directly, or via the
asyncpipe). - Subscribing manually inside a component and forgetting to unsubscribe in
ngOnDestroy— a very common real-world memory leak, especially for long-lived streams like a polling interval or a WebSocket. - Reaching for manual
.subscribe()in a template-driven display case where theasyncpipe would remove the need for lifecycle management entirely. - Confusing
HttpClient's Observable with a Promise and trying to.then()it directly — you must.subscribe()(or usefirstValueFrom()to convert it to a Promise when a Promise is genuinely what you need).