Observables and Subscriptions — Cold vs Hot and the Subscription Lifecycle

📋 Table of Contents
  1. Observable Fundamentals
  2. Common Mistakes

An Observable is a lazy, asynchronous data stream — it produces values over time and can be subscribed to. Cold Observables start a new execution per subscriber (each HTTP call with HttpClient is cold — each subscribe() sends a new request). Hot Observables have a shared execution regardless of subscriber count (a DOM click event stream is hot — the event fires once regardless of how many listeners exist). Understanding this distinction prevents the most common RxJS bug: triggering duplicate HTTP requests by subscribing multiple times to the same cold Observable.

Observable Fundamentals

import { Observable, of, from, interval, timer, fromEvent,
         EMPTY, NEVER, throwError } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

// ── Cold Observable — new execution per subscriber ────────────────────────
const cold$ = new Observable<number>(subscriber => {
  console.log('Execution started');
  subscriber.next(1);
  subscriber.next(2);
  subscriber.next(3);
  subscriber.complete();
});

cold$.subscribe(v => console.log('A:', v));  // "Execution started" A:1 A:2 A:3
cold$.subscribe(v => console.log('B:', v));  // "Execution started" B:1 B:2 B:3
// Each subscribe starts a completely independent execution

// ── Creation operators ─────────────────────────────────────────────────────
of(1, 2, 3)               // emits 1, 2, 3 then completes — synchronous
from([1, 2, 3])           // emits each array element — synchronous
from(fetch('/api/data'))  // wraps a Promise — async, cold
interval(1000)            // emits 0, 1, 2... every 1 second — never completes
timer(2000, 1000)         // waits 2s then emits every 1s
fromEvent(document, 'click')  // hot — fires for every document click
EMPTY                     // completes immediately without emitting
NEVER                     // never emits and never completes
throwError(() => new Error('Oops'))  // emits an error immediately

// ── Subscription lifecycle ─────────────────────────────────────────────────
const sub = interval(1000).subscribe({
  next:     value => console.log(value),      // 0, 1, 2, 3...
  error:    err   => console.error(err),       // on error
  complete: ()    => console.log('Done'),      // on completion
});

// Cancel after 5 seconds
setTimeout(() => sub.unsubscribe(), 5000);

// ── The duplicate request problem (cold Observable) ────────────────────────
const posts$ = this.http.get<PostDto[]>('/api/posts');  // no request yet

// ❌ WRONG — subscribes twice, sends TWO HTTP requests
const firstPost = posts$.pipe(map(p => p[0]));
firstPost.subscribe(p => console.log(p));  // HTTP request 1
firstPost.subscribe(p => this.post = p);   // HTTP request 2

// ✅ Correct — subscribe once, share with shareReplay(1)
const sharedPosts$ = posts$.pipe(shareReplay(1));
sharedPosts$.subscribe(p => console.log(p));  // HTTP request
sharedPosts$.subscribe(p => this.post = p[0]); // no new request — replayed

// ── Unsubscription patterns ────────────────────────────────────────────────
// 1. takeUntilDestroyed — preferred in components (Angular 16+)
interval(1000).pipe(takeUntilDestroyed()).subscribe(v => console.log(v));

// 2. async pipe — auto-unsubscribes in templates
// <p>{{ count$ | async }}</p>

// 3. Manual unsubscription — for non-component contexts
const sub2 = interval(1000).subscribe();
ngOnDestroy() { sub2.unsubscribe(); }
Note: The key practical implication of cold Observables: every time you call .subscribe() on an HttpClient Observable, a new HTTP request is sent. Using the async pipe in a template is deceptively expensive if used multiple times on the same Observable — each pipe instance creates its own subscription, triggering separate HTTP requests. Always use async once with the as syntax (*ngIf="posts$ | async as posts"), or convert to a signal with toSignal(), or add shareReplay(1) to the Observable to make it safe to subscribe multiple times.
Tip: Use marble diagrams mentally when reasoning about RxJS operators. A marble diagram represents time on a horizontal axis with circles (marbles) for emitted values, | for completion, and X for errors. When you see switchMap, imagine a new inner Observable starting and the previous one being cancelled whenever the source emits. When you see combineLatest, imagine two lanes that emit a combined value every time either lane emits (after both have emitted at least once). Marble thinking makes operator behaviour intuitive.
Warning: Forgetting to unsubscribe from long-lived Observables is the most common RxJS memory leak. HttpClient Observables complete after one emission — no cleanup needed. But interval(), fromEvent(), Router.events, ActivatedRoute.params, and FormControl.valueChanges never complete — they hold references to the subscriber indefinitely unless unsubscribed. Use takeUntilDestroyed() in components for all non-completing subscriptions.

Common Mistakes

Mistake 1 — Using async pipe multiple times on the same cold Observable (multiple HTTP calls)

❌ Wrong — {{ posts$ | async }} and (posts$ | async)?.length — two HTTP requests.

✅ Correct — @if (posts$ | async; as posts) — one subscription, use posts throughout.

Mistake 2 — Not unsubscribing from interval/fromEvent (memory leak)

❌ Wrong — interval(1000).subscribe() in ngOnInit without cleanup; subscription lives until page reload.

✅ Correct — interval(1000).pipe(takeUntilDestroyed()).subscribe().

🧠 Test Yourself

A service returns http.get('/api/posts'). A component subscribes twice to the returned Observable. How many HTTP requests are sent?