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."

Typescript
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:

Typescript
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

Typescript
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:

Typescript
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:

Typescript
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 async pipe).
  • 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 the async pipe 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 use firstValueFrom() to convert it to a Promise when a Promise is genuinely what you need).