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(); }
.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.| 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.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().