An observable is a description of something that will push values to you over time. It can push zero, one or many values, and then it either completes (finished normally) or errors (finished with a failure) — or it never finishes at all, like a stream of clicks. Angular uses observables for HTTP responses, router events, form value changes and anything else that "happens later, maybe more than once".
The single most useful mental model: an observable is a function, not a value. Creating one just writes down a recipe — "when someone subscribes, start a timer and call them back with numbers". Nothing runs until you call subscribe(), and every subscribe() runs the recipe again, from the top, just for that subscriber. Think of it as a newspaper subscription rather than a parcel: nothing is printed for you until you subscribe, you then get issue after issue, the paper may eventually close down (complete) or go bust (error), and you can cancel at any time (unsubscribe).
The contract every observable follows is next* (error | complete)? — any number of next values, then at most one of error or complete, and after that, silence. The function you return from the recipe is the teardown: RxJS calls it when the stream finishes or when the subscriber unsubscribes, which is where timers get cleared and requests get aborted.
mental-model.mjs
import{ Observable }from'rxjs';const start = Date.now();constlog=(...args)=> console.log(`${String(Date.now()- start).padStart(3)}ms`,...args);const numbers$ =newObservable((subscriber)=>{log('producer starts');
subscriber.next(1);
subscriber.next(2);const id =setTimeout(()=>{
subscriber.next(3);
subscriber.complete();
subscriber.next(4);// ignored: the stream already completed},100);return()=>{log('teardown');clearTimeout(id);};});log('observable created');
numbers$.subscribe({next:(v)=>log('next', v),complete:()=>log('complete'),});log('subscribe() returned');// 0ms observable created// 0ms producer starts// 0ms next 1// 0ms next 2// 0ms subscribe() returned// 100ms next 3// 100ms complete// 100ms teardown
What’s happening
new Observable(fn) only storesfn. That's why observable created is logged first and producer starts hasn't appeared yet: no subscriber, no work.
numbers$.subscribe({...}) calls fn with a subscriber object wrapping our next/complete callbacks. The producer runs synchronously, inside the subscribe() call: next(1) and next(2) reach our callback before subscribe() even returns — observables are not automatically async.
The producer schedules a timer and returns the teardown function. Only now does subscribe() return, so subscribe() returned is logged after next 2.
At 100ms the timer fires: next(3) is delivered, then complete() ends the stream. RxJS immediately runs the teardown (teardown logged) because a finished stream no longer needs its resources.
next(4) is silently dropped: after complete, the subscriber is closed, so the contract next* (error | complete)? is enforced for you even if the producer misbehaves.
The $ suffix on numbers$ is just a naming convention meaning "this is an observable". It has no effect on behaviour.
AdvancedObservables can emit many times
My notes say it plainly: observables can emit more than once. That's the headline difference from a promise, and the reason all the operators exist — you need tools for transforming, filtering, combining and cancelling sequences, not single values.
Both represent "a result that arrives later", which is why they get compared in every interview. The differences all fall out of the mental model above: a promise is a value (a placeholder for one result that is already being produced), while an observable is a function (a recipe that runs per subscriber and can deliver many results).
import{ Observable, interval, take, firstValueFrom, lastValueFrom }from'rxjs';const promise =newPromise((resolve)=>{
console.log('promise executor runs');resolve(1);resolve(2);// ignored: a promise settles once});const obs$ =newObservable((subscriber)=>{
console.log('observable producer runs');
subscriber.next(1);
subscriber.next(2);
subscriber.complete();});
console.log('--- nothing subscribed yet ---');
promise.then((v)=> console.log('promise value', v));
obs$.subscribe((v)=> console.log('observable value', v));
console.log('--- end of script ---');setTimeout(async()=>{
console.log('first:',awaitfirstValueFrom(interval(10).pipe(take(3))));
console.log('last:',awaitlastValueFrom(interval(10).pipe(take(3))));},20);// promise executor runs// --- nothing subscribed yet ---// observable producer runs// observable value 1// observable value 2// --- end of script ---// promise value 1// first: 0// last: 2
What’s happening
new Promise(...) runs its executor right away, so promise executor runs is the very first line — before anyone has asked for the result. The second resolve(2) is ignored: a promise settles once.
new Observable(...) runs nothing. Its producer only runs when obs$.subscribe(...) is called, which is why observable producer runs appears after the nothing subscribed yet line.
The observable delivers 1 and 2synchronously, inside subscribe(), so both print before end of script.
The promise's then callback is a microtask, so promise value 1 waits until the script finishes — even though the promise was resolved long before. Promises are always async; observables are only as async as their source.
firstValueFrom and lastValueFrom bridge the other way: they subscribe and return a promise for the first or last value, so you can await an observable. firstValueFrom unsubscribes after the first value (0); lastValueFrom waits for completion and gives 2. Both reject with EmptyError: no elements in sequence if the observable completes without emitting — and lastValueFrom never settles on an observable that never completes.
AdvancedWhen to use observables or promises
For the promise side in depth — the executor running synchronously, chaining, microtasks — see Promises: chaining and the executor on the Node site.
When to use which in Angular: HttpClient returns observables, so you'll use them for HTTP whether you like it or not; they shine when you need cancellation (a typeahead), multiple values (WebSocket messages, form changes) or time-based operators (debounce, retry with delay). For a single value you just want to await — in a guard, a resolver, a one-off script — firstValueFrom is perfectly fine. For UI state, prefer signals (Signals and RxJS together).
?Which lines print?
JavaScript
const p =newPromise((resolve)=>{ console.log('P');resolve();});const o =newObservable((s)=>{ console.log('O'); s.next();});
console.log('end');
Show answer
P, then end. O never prints.
What’s happening
The promise executor runs during new Promise(...) → P.
new Observable(...) only stores the producer function; nobody calls o.subscribe(), so the producer never runs.
console.log('end') → end.
This is "eager vs lazy" in three lines — and it's why this.http.post(...) without a subscribe() (or an async pipe) never sends anything.
subscribe() is the moment an observable comes to life. You pass it an observer — an object with up to three callbacks, next, error and complete — or just a function, which is shorthand for { next: fn }. It returns a Subscription, your handle for stopping it: subscription.unsubscribe() runs the teardown and guarantees no more callbacks.
Who unsubscribes is the whole game in Angular. An observable that completes on its own (an HTTP request, of(...), take(3)) cleans itself up. One that never completes (an interval, a Subject, valueChanges, router events) keeps your callback — and everything it closes over, including your component — alive until someone unsubscribes. That's the classic RxJS memory leak.
subscribe.mjs
import{ Observable }from'rxjs';const ticks$ =newObservable((subscriber)=>{let n =0;
console.log('timer started');const id =setInterval(()=> subscriber.next(n++),100);return()=>{clearInterval(id);
console.log('teardown: timer cleared');};});const sub = ticks$.subscribe({next:(n)=> console.log('tick', n),error:(err)=> console.log('error', err.message),complete:()=> console.log('complete'),// never called: we unsubscribe instead});setTimeout(()=>{
sub.unsubscribe();
console.log('closed?', sub.closed);},350);// timer started// tick 0// tick 1// tick 2// teardown: timer cleared// closed? true
What’s happening
subscribe() runs the producer: it logs timer started, starts a setInterval, and returns the teardown function, which RxJS stores inside the returned Subscription.
Every 100ms the interval calls subscriber.next(n++) → tick 0, tick 1, tick 2 (at roughly 100, 200 and 300ms).
At 350ms sub.unsubscribe() runs the stored teardown → clearInterval and teardown: timer cleared. Without this line the interval would tick forever.
complete never fires. Unsubscribing is not completing: it's the consumer walking away, not the producer saying "done". Code that should run in both cases belongs in finalize (Error handling with catchError and finalize).
sub.closed is true afterwards; calling unsubscribe() again is harmless.
AdvancedWays to subscribe and when to use each
Ways to subscribe, and which to use:
Observer objectsubscribe({ next, error, complete }) — the recommended form. The old positional form subscribe(next, error, complete) is marked @deprecated in RxJS 7.8's typings ("Signatures taking separate callback arguments will be removed in v8"); a single function argument is still fine.
No error callback — if the stream errors and nobody handles it, RxJS rethrows the error asynchronously as an unhandled error (in Node it crashes the process; in the browser it shows up as an uncaught error in the console).
const sub =of(1,2,3).subscribe((v)=>{
console.log('got', v);
sub.unsubscribe();});
console.log('after subscribe');
Show answer
It prints got 1, got 2, got 3, after subscribe — and then reports threeReferenceError: Cannot access 'sub' before initialization errors as uncaught exceptions (in Node, the first one crashes the process).
What’s happening
of(1, 2, 3) emits synchronously, so all three next calls happen insidesubscribe() — before subscribe() has returned and before const sub is assigned.
In the first callback, sub.unsubscribe() reads sub while it's still in the temporal dead zone → ReferenceError.
That error is thrown from your callback, so RxJS reports it asynchronously instead of stopping the stream (see the gotcha above). Values 2 and 3 are still delivered, each throwing again.
after subscribe prints, then the three reported errors surface.
The fix is to let an operator stop the stream: of(1, 2, 3).pipe(take(1)).subscribe(...) prints just got 1.
My notes have this in capitals: don't subscribe multiple times — multiple subscriptions mean multiple executions. It follows directly from "an observable is a function": calling a function twice runs it twice. With HttpClient, every subscription is a separate HTTP request.
What makes it sneaky is that the extra subscriptions are often hidden. Two maps off the same source, two | async pipes on the same observable in a template, a subscribe() in a component plus an async pipe in its template — each is a separate trip to the server. pipe() doesn't share anything; it builds a new recipe that, when subscribed, subscribes to the source again.
multiple-subscriptions.mjs
import{ Observable, map, shareReplay }from'rxjs';let executions =0;// Behaves like HttpClient.get(): nothing happens until subscribe, then one "request" per subscriber.constgetUser=()=>newObservable((subscriber)=>{
executions++;
console.log(`request #${executions} sent`);const id =setTimeout(()=>{
subscriber.next({id:1,name:'Ada',email:'ada@example.com'});
subscriber.complete();},100);return()=>clearTimeout(id);});const user$ =getUser();const name$ = user$.pipe(map((u)=> u.name));const email$ = user$.pipe(map((u)=> u.email));
name$.subscribe((n)=> console.log('name:', n));
email$.subscribe((e)=> console.log('email:', e));// request #1 sent// request #2 sent// name: Ada// email: ada@example.com// → executions: 2const shared$ =getUser().pipe(shareReplay({bufferSize:1,refCount:true}));
shared$.pipe(map((u)=> u.name)).subscribe((n)=> console.log('name:', n));
shared$.pipe(map((u)=> u.email)).subscribe((e)=> console.log('email:', e));// request #1 sent// name: Ada// email: ada@example.com// → executions: 1// …later, after the response arrived:
shared$.subscribe((u)=> console.log('late subscriber gets cached:', u.name));// late subscriber gets cached: Ada// → executions: still 1
What’s happening
getUser() returns a cold observable that counts how many times its producer runs — a stand-in for http.get('/api/users/1').
name$ and email$ are two pipes off the same user$. Subscribing to name$ subscribes map, which subscribes user$ → producer runs → request #1 sent. Subscribing to email$ does the same thing again → request #2 sent. Two subscribers, two executions, two requests.
shareReplay({ bufferSize: 1, refCount: true }) puts a single shared subscription (backed by a ReplaySubject) between the source and its subscribers. The first subscriber triggers one execution; the second just joins the same one → only request #1 sent, and both name and email print from that one response.
bufferSize: 1 means it also remembers the last value. A subscriber that arrives after the response gets Ada immediately, from the cache, with no new request.
refCount: true means "if every subscriber leaves before the source finishes, unsubscribe from the source" (cancel the request). For a source that has already completed, the cached value stays. See Hot vs cold observables for why this flag matters.
AdvancedDuplicate HTTP requests from two async pipes
The same thing in an Angular template, verified with HttpTestingController in a Vitest spec:
user-twice.ts
// ❌ Two async pipes on the same cold observable = two subscriptions = two HTTP requests.@Component({
selector:'app-user-twice',
imports:[AsyncPipe],
template:`
<h2>{{ (user$ | async)?.name }}</h2>
<p>{{ (user$ | async)?.email }}</p>
`,})exportclassUserTwice{
user$ =inject(UserApi).getUser(1);}// ✅ One async pipe, aliased with `as`.@Component({
selector:'app-user-once',
imports:[AsyncPipe],
template:`
@if (user$ | async; as user) {
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
} @else {
<p>Loading…</p>
}
`,})exportclassUserOnce{
user$ =inject(UserApi).getUser(1);}
What’s happening
UserApi.getUser(1) is this.http.get<User>('/api/users/1') — a cold observable. Assigning it to user$ sends nothing.
In UserTwice each | async is its own AsyncPipe instance, and each one subscribes when the template first renders. The spec's http.match('/api/users/1') found 2 pending requests (UserTwice requests: 2).
In UserOnce there is one async pipe; as user stores its latest value in a template variable that the whole @if block reads. The spec found 1 request (UserOnce requests: 1).
Before the response, the async pipe returns null, so @if falls through to @else — the spec read Loading…. After req.flush(ada) it rendered Ada and the email.
Other fixes for the same problem: shareReplay(1) on the observable, or convert it once with toSignal() and read the signal as often as you like (The async pipe and toSignal()).
?Does piping share?
JavaScript
let n =0;const req$ =newObservable((s)=>{ n++; s.next(n); s.complete();});const a$ = req$.pipe(map((x)=> x *10));const b$ = a$.pipe(map((x)=> x +1));
a$.subscribe((v)=> console.log('a', v));
b$.subscribe((v)=> console.log('b', v));
console.log('executions', n);
Show answer
a 10, b 21, executions 2.
What’s happening
a$.subscribe → map subscribes to req$ → producer runs, n becomes 1, emits 1 → mapped to 10 → a 10.
b$ is built froma$, but that doesn't mean it reuses a$'s execution. b$.subscribe → the outer map subscribes to a$ → its map subscribes to req$ → producer runs again, n becomes 2, emits 2 → 20 → 21 → b 21.
executions 2: each subscription walks all the way back up the chain to the source.
Building pipelines from other pipelines is fine and encouraged — just know it shares code, not executions. Add share()/shareReplay() when you need one execution.
A cold observable creates its producer per subscriber: HttpClient requests, timer, interval, of, from([...]). Each subscriber gets its own private execution, starting from the beginning. A hot observable has a producer that exists independently of subscribers: DOM events, a WebSocket that's already open, a Subject, router events. Subscribers tap into whatever is happening now; if you subscribe late, you miss what already went by.
Analogy: cold is a video on demand — everyone who presses play gets the film from the start, on their own stream. Hot is live TV — everyone watches the same broadcast, and tuning in late means you missed the opening. Multicasting operators turn cold into hot: share() and shareReplay() put a Subject in front of the source so many subscribers share one execution.
hot-vs-cold.mjs
import{ interval, take, share, timer }from'rxjs';// Cold: each subscriber gets its own interval, starting from 0.const cold$ =interval(100).pipe(take(3));
cold$.subscribe((v)=>log('A got', v));timer(150).subscribe(()=> cold$.subscribe((v)=>log('B got', v)));// 100ms A got 0// 200ms A got 1// 250ms B got 0// 300ms A got 2// 350ms B got 1// 450ms B got 2// Hot (shared): one interval, B joins mid-way.const hot$ =interval(100).pipe(take(3),share());
hot$.subscribe((v)=>log('A got', v));timer(150).subscribe(()=> hot$.subscribe((v)=>log('B got', v)));// 100ms A got 0// 200ms A got 1// 200ms B got 1// 300ms A got 2// 300ms B got 2
Cold: A subscribes at 0ms and starts its own interval → 0, 1, 2 at 100/200/300ms. B subscribes at 150ms and starts another interval, so B's 0 arrives 100ms later, at 250ms. B sees the whole sequence, just shifted.
Hot: share() subscribes to the source once, when A arrives, and forwards every value to everyone currently subscribed. B joins at 150ms, after 0 was already emitted, so B misses 0 and gets 1 and 2 at the same moments as A.
Same source, same operators — the only difference is whether each subscriber triggers its own execution (cold) or joins a shared one (hot).
Advancedshare() vs shareReplay() for late subscribers
The details of share() and shareReplay() decide whether late subscribers re-trigger the request or get a cached value — and whether the source can leak:
defer(() => ...) runs its factory on every subscription, so user$ is a cold "request" that logs and counts each execution, answering Ada after 100ms.
share(): A subscribes at 0ms → request #1, answer at 100ms. Then the source completes, and share() by default resets on completion (resetOnComplete: true) — it forgets the finished execution and keeps no value.
So when B subscribes at 200ms, share() has nothing to give it and subscribes to the source again → request #2, answer at 300ms. A late subscriber to share() after completion causes a new execution.
shareReplay({ bufferSize: 1, refCount: true }): A subscribes at 400ms → request #3, answer at 500ms, and the value is kept in a ReplaySubject(1). shareReplay does not reset on completion, so B at 600ms gets Ada instantly, with no new request.
Rule of thumb: share() for live streams where late subscribers should only see future values; shareReplay({ bufferSize: 1, refCount: true }) for "fetch once, give everyone the latest" (config, the current user).
AdvancedshareReplay and the refCount flag
The refCount flag matters for sources that never complete:
Both pipelines share an infinite interval; the tap sits before the share, so it shows when the source itself is running.
Until 250ms both sources tick, each with one subscriber.
At 250ms both subscribers unsubscribe. With refCount: true, the subscriber count dropped to zero, so shareReplay unsubscribes from its source → the tidy interval stops; no more tidy lines.
shareReplay(1) (the number-only form) defaults to refCount: false: it keeps its own subscription to the source alive forever, even with zero subscribers. The leaky interval keeps ticking — in a component, that's a leak that outlives the component.
For an HTTP request this rarely matters (it completes on its own), but make { bufferSize: 1, refCount: true } your default spelling so you never have to think about it.
100ms A 0
200ms A 1
300ms A 2
300ms A complete
450ms B 0
550ms B 1
650ms B 2
650ms B complete
What’s happening
A subscribes at 0ms; share() subscribes to the source once → A 0, A 1, A 2 at 100/200/300ms, and take(3) completes the source at 300ms → A complete.
On completion share() resets: its internal Subject is thrown away and it unsubscribes from the source.
B arrives at 350ms, after the reset. To share() this looks like a brand-new first subscriber, so it subscribes to the source again — a freshinterval starting at 350ms.
B therefore gets the whole sequence from 0, at 450/550/650ms. B doesn't "miss" anything here, because there was nothing in progress to join.
With shareReplay(1) instead, B would get only 2 (the cached last value) immediately at 350ms, then B complete.
An operator is just a function that takes an observable and returns a new observable. map((x) => x * 2) doesn't do anything to values by itself — it returns a function source$ => newObservable$, and the new observable, when subscribed, subscribes to source$ and forwards transformed values. pipe() chains these functions: source$.pipe(a, b, c) is exactly c(b(a(source$))), written left to right so it reads in the order data flows.
Two consequences matter every day. First, pipe() never changes the source — it returns a new observable, so obs$.pipe(map(...)) on its own line, with the result thrown away, does nothing. Second, pipe() is still lazy — it only builds a longer recipe; nothing runs until the final observable is subscribed. Picture a factory line that's been assembled but not switched on.
pipe.mjs
import{ Observable,of, map, filter }from'rxjs';// A hand-written operator: a function from Observable to Observable.functiondouble(){return(source)=>newObservable((subscriber)=>{
console.log('double: subscribing to its source');const sub = source.subscribe({next:(v)=> subscriber.next(v *2),error:(e)=> subscriber.error(e),complete:()=> subscriber.complete(),});return()=> sub.unsubscribe();});}const source$ =of(1,2,3);const doubled$ = source$.pipe(double());
console.log('same object?', doubled$ === source$);
console.log('pipe() returned, nothing ran yet');
doubled$.subscribe((v)=> console.log('doubled', v));
source$.subscribe((v)=> console.log('original', v));// pipe(a, b) is just b(a(source))const viaPipe$ = source$.pipe(filter((n)=> n %2===1),map((n)=> n *10));const byHand$ =map((n)=> n *10)(filter((n)=> n %2===1)(source$));
viaPipe$.subscribe((v)=> console.log('viaPipe', v));
byHand$.subscribe((v)=> console.log('byHand', v));// same object? false// pipe() returned, nothing ran yet// double: subscribing to its source// doubled 2// doubled 4// doubled 6// original 1// original 2// original 3// viaPipe 10// viaPipe 30// byHand 10// byHand 30
What’s happening
double() returns the operator function (source) => new Observable(...). Inside, the new observable subscribes to source and calls subscriber.next(v * 2) for each value, passing error and complete straight through. Every built-in operator is this shape (with more care around edge cases).
source$.pipe(double()) calls that function with source$ and returns the new observable → same object? false. Nothing has subscribed, so double: subscribing to its source hasn't printed yet.
doubled$.subscribe(...) subscribes the outer observable, which subscribes to source$ → of emits 1, 2, 3 → doubled to 2, 4, 6.
source$ itself is untouched: subscribing to it directly still gives 1, 2, 3. Operators never mutate their source.
viaPipe$ and byHand$ produce identical output because pipe() is just function composition — filter keeps 1 and 3, map turns them into 10 and 30.
The returned teardown () => sub.unsubscribe() is what makes unsubscribing propagate: unsubscribing from the end of a chain unsubscribes every link back to the source.
AdvancedBug: a request that never subscribes
The bug this explains, which I've seen in real code:
TypeScript
save(){this.http.post('/api/todos',this.todo).pipe(tap(()=>this.toast('Saved')));// ❌ Nothing is sent: the observable is created, piped, and thrown away.}
AdvancedFixing the unsubscribed request
There's no subscribe() (and no async pipe or toSignal()), so the request recipe is never executed. Add .subscribe() — or better, return the observable and let the caller decide.
?Thrown-away pipe
JavaScript
const o$ =of(1,2,3);
o$.pipe(map((x)=> x *2));
o$.subscribe((v)=> console.log(v));
Show answer
1, 2, 3.
What’s happening
o$.pipe(map(...)) returns a new observable, which is discarded on the same line.
o$ is unchanged — operators never modify their source.
o$.subscribe(...) runs the original of(1, 2, 3) → 1, 2, 3.
To get 2, 4, 6, keep the result: const doubled$ = o$.pipe(map((x) => x * 2)); doubled$.subscribe(...).
My notes called these "confusing as hell", and I think the confusion has two sources. The first: they look like the array methods but run per value, over time — one value travels through the whole pipe before the next value even starts. The second, and the bigger one: when an observable emits an array (which is what http.get<User[]>() does), RxJS's map/filter work on the whole array as one value, and you need the array'smap/filter inside them.
The roles, with a conveyor-belt picture — each value is a box riding past stations:
map(fn) — a station that replaces each box with fn(box). One in, one out.
filter(pred) — a station that lets a box through or throws it off. One in, one or zero out. The value itself is never changed.
tap(fn) — a window where you look at each box (log it, store it) without touching it. Whatever fn returns is ignored; the original box continues.
order.mjs
import{of, map, filter, tap }from'rxjs';
console.log('--- Array: each stage runs over the WHOLE array, then the next stage ---');[1,2,3,4].filter((n)=>{ console.log('filter', n);return n %2===0;}).map((n)=>{ console.log('map', n);return n *10;}).forEach((n)=> console.log('result', n));
console.log('--- RxJS: each VALUE runs through the whole pipe before the next value ---');of(1,2,3,4).pipe(tap((n)=> console.log('tap in', n)),filter((n)=> n %2===0),map((n)=> n *10),tap((n)=> console.log('tap out', n)),).subscribe((n)=> console.log('result', n));// --- Array: each stage runs over the WHOLE array, then the next stage ---// filter 1// filter 2// filter 3// filter 4// map 2// map 4// result 20// result 40// --- RxJS: each VALUE runs through the whole pipe before the next value ---// tap in 1// tap in 2// tap out 20// result 20// tap in 3// tap in 4// tap out 40// result 40
What’s happening
The array chain is stage by stage: filter runs on all four elements and builds a new array [2, 4]; only then does map run on that, building [20, 40]; then forEach. Every stage needs the whole collection.
The RxJS chain is value by value: of pushes 1 → tap in 1 → filter rejects it, so 1 goes no further (no tap out, no result).
of pushes 2 → tap in 2 → filter passes it → map turns it into 20 → tap out 20 → result 20. Value 2 reached the subscriber before value 3 was even emitted.
3 is rejected like 1; 4 goes all the way to result 40. No intermediate arrays are built — which is why this works for infinite streams like clicks.
The two taps are windows, not stations: they let you see what each stage receives without changing anything. That's what tap is for — debugging and side effects like logging, setting a loading flag, or caching.
AdvancedTrap: filtering an array emission
Now the real trap. HttpClient.get<User[]>() emits once, and that one value is the whole array:
array-confusion.ts
interfaceUser{ name:string; active:boolean;}// Like http.get<User[]>('/api/users'): ONE emission, and that emission is the whole array.const users$: Observable<User[]>=of([{ name:'Ada', active:true},{ name:'Bob', active:false},{ name:'Cy', active:true},]);const wrong$ = users$.pipe(filter((user)=> user.active));// ❌ TS2339: Property 'active' does not exist on type 'User[]'.const right$ = users$.pipe(map((users)=> users.filter((user)=> user.active)));// emits once: [{ name: 'Ada', … }, { name: 'Cy', … }]const nested$ = users$.pipe(map((users)=>of(users.length)));// type: Observable<Observable<number>> — map doesn't flattenconst tapped$ = users$.pipe(tap((users)=> users.length));// type: Observable<User[]> — tap's return value is ignored
What’s happening
users$ is Observable<User[]>: a stream of arrays, which happens to contain a single array. Read the type parameter as "each emission is a User[]".
wrong$: RxJS's filter receives each emission, so its parameter (named user here, misleadingly) is the whole User[]. TypeScript catches it: TS2339: Property 'active' does not exist on type 'User[]'. In plain JavaScript there's no error — array.active is undefined, which is falsy, so the filter drops the one emission and the stream just completes with nothing (verified: no next, then complete). That silent empty result is the "confusing as hell" moment.
right$: RxJS map receives the one array and returns a new array, built with Array.prototype.filter inside it → emits ['Ada', 'Cy'] (as user objects). Outer operator for the stream, inner array method for the contents.
nested$: if your map callback returns an observable, you get an observable of observables — RxJS doesn't unwrap it. Subscribing gives you an Observable object as the value, not 3. Flattening is what switchMap/mergeMap/concatMap/exhaustMap are for (switchMap).
tapped$: tap((users) => users.length) computes a number and throws it away; the type stays Observable<User[]> and the array passes through unchanged. If you wanted the length downstream, that's map.
filter — drop values you don't want: filter((text) => text.length >= 2) in a typeahead, filter((e) => e instanceof NavigationEnd) on router events. With a type-guard predicate, filter also narrows the type downstream.
tap — side effects that shouldn't change the data: logging, this.loading.set(false), writing to a cache. If you catch yourself returning something from tap, you wanted map; if you catch yourself doing side effects in map and returning the input unchanged, you wanted tap.
?tap, filter and map together
JavaScript
of(1,2,3).pipe(tap((x)=> x *100),filter((x)=> x !==2),map((x)=> x +1),).subscribe((v)=> console.log(v));
Show answer
2, then 4.
What’s happening
1 → tap computes 100 and discards it, so 1 continues → filter keeps it (1 !== 2) → map gives 2 → logged.
2 → tap passes it through → filter drops it. Nothing logged.
3 → tap passes it → filter keeps it → map gives 4 → logged.
If you expected 101 and 301, you read tap as map. tap's return value never reaches the next operator.
of and from are creation functions: they turn ordinary values into observables. Both emit synchronously (for synchronous inputs) and then complete. The difference is one level of unpacking:
of(a, b, c) emits each argument exactly as it is. of([1, 2, 3]) emits one value: the array.
from(x) takes one thing and emits what's inside it: each element of an array or iterable, each character of a string, the resolved value of a promise, or the values of another observable.
My notes also had "of vs off". There is no off in RxJS. The word comes from event-emitter APIs (emitter.off('click', handler) in Node, jQuery's .off()), where it removes a listener. In RxJS the equivalent of "off" is unsubscribing — and fromEvent() does the listener removal for you when you unsubscribe.
of(1, 2, 3) has three arguments → three nexts, then complete.
of([1, 2, 3]) has one argument, the array → onenext with the whole array. This is what HttpClient effectively gives you for a list endpoint.
from([1, 2, 3]) unpacks the array → three separate nexts. from('hi') unpacks a string (it's iterable) into characters.
of() with no arguments completes immediately without emitting — handy as an "empty" result (RxJS also has the constant EMPTY).
Timing: of('of value') emits synchronously, between before and after. from(promise) emits when the promise's then fires — a microtask — so promise value comes after after. from inherits the timing of what you give it.
AdvancedEager arguments vs defer
The subtle part is when the value is computed. of(x) and from(promise) receive something that already exists:
of(roll()) — JavaScript evaluates the argument beforeof is even called, so roll() runs once, at creation (calls is already 1). Every subscriber gets that same stored 1.
defer(() => of(roll())) stores a factory and calls it per subscription, so each subscriber triggers its own roll() → 2, then 3. defer is how you make something lazy that otherwise wouldn't be.
from(fakeFetch()) — fakeFetch() runs immediately (promises are eager), so fetch started prints and fetches is 1before anyone subscribes. Both subscribers then get the single promise's result; the fetch is not repeated. This observable is neither lazy nor re-runnable, and unsubscribing can't stop it.
defer(() => fakeFetch()) starts a new fetch per subscriber, which makes it behave like a real cold HttpClient call: two subscriptions, two fetches (the run shows fetch started twice and total fetches: 3).
Rule: wrap promise-returning calls in defer(() => ...) when you want "nothing happens until subscribe" (e.g. inside retry, which works by resubscribing — a pre-made promise can't be retried).
AdvancedfromEvent adds and removes listeners
And "off", the RxJS way — fromEvent adds the listener on subscribe and removes it on unsubscribe:
from-event.mjs
import{ fromEvent }from'rxjs';import{ EventEmitter }from'node:events';const emitter =newEventEmitter();const clicks$ =fromEvent(emitter,'click');const sub = clicks$.subscribe((e)=> console.log('click', e));
console.log('listeners while subscribed:', emitter.listenerCount('click'));
emitter.emit('click','first');
sub.unsubscribe();
console.log('listeners after unsubscribe:', emitter.listenerCount('click'));
emitter.emit('click','second (nobody hears this)');// listeners while subscribed: 1// click first// listeners after unsubscribe: 0
What’s happening
fromEvent(emitter, 'click') is lazy like any observable: no listener exists until subscribe().
Subscribing registers one listener (listenerCount is 1), and emit('click', 'first') reaches it.
sub.unsubscribe() runs the teardown, which removes the listener — RxJS's version of off(). The count drops to 0.
The second emit has no listener, so nothing prints. In the browser it's the same with fromEvent(button, 'click') and removeEventListener.
?of vs from
JavaScript
from([[1,2],[3]]).subscribe((v)=> console.log('A',JSON.stringify(v)));of(...[1,2]).subscribe((v)=> console.log('B', v));from(of(1,2)).subscribe((v)=> console.log('C', v));of(of(1)).subscribe((v)=> console.log('D', v instanceofObservable));
Show answer
A [1,2], A [3], B 1, B 2, C 1, C 2, D true.
What’s happening
from unpacks one level: the outer array has two elements, [1, 2] and [3], so A gets two arrays.
of(...[1, 2]) — the spread turns it into of(1, 2), two arguments → B 1, B 2.
from(observable) just re-emits that observable's values → C 1, C 2.
of(of(1)) emits its one argument as-is — an Observable object → D true. Nothing flattens it; that's the job of the *Map operators.
An error is a terminal notification: once an observable errors, it's finished — no more values, ever, and every operator between the error and the subscriber is unsubscribed. If nothing handles it, the subscriber's error callback gets it, or RxJS reports it as unhandled.
catchError(handler) intercepts an error and replaces the failed stream with another observable returned by handler. Return of(fallback) to substitute a value, EMPTY to swallow it and complete, or throwError(() => newError) to rethrow something friendlier. What catchError can't do is "resume" the original stream: the source that errored is gone. finalize(fn) is the observable version of finally: it runs once when the stream ends for any reason — complete, error, or unsubscribe.
errors.mjs
import{of, map, catchError, finalize, throwError,EMPTY, timer }from'rxjs';constwatch=(label)=>({next:(v)=> console.log(label,'next', v),error:(e)=> console.log(label,'error', e.message),complete:()=> console.log(label,'complete'),});const failOn2 =map((n)=>{if(n ===2)thrownewError('bad 2');return n;});of(1,2,3).pipe(failOn2).subscribe(watch('A'));// A next 1// A error bad 2of(1,2,3).pipe(failOn2,catchError((err)=>{ console.log('caught', err.message);returnof(-1);})).subscribe(watch('B'));// B next 1// caught bad 2// B next -1// B completethrowError(()=>newError('nope')).pipe(catchError(()=>EMPTY)).subscribe(watch('C'));// C completethrowError(()=>newError('500 Internal Server Error')).pipe(catchError((err)=>throwError(()=>newError(`Could not load users (${err.message})`)))).subscribe(watch('D'));// D error Could not load users (500 Internal Server Error)of('x').pipe(finalize(()=> console.log('E finalize'))).subscribe(watch('E'));// E next x// E complete// E finalizethrowError(()=>newError('fail')).pipe(finalize(()=> console.log('F finalize'))).subscribe(watch('F'));// F error fail// F finalizetimer(1000).pipe(finalize(()=> console.log('G finalize'))).subscribe(watch('G')).unsubscribe();// G finalize
What’s happening
A: map throws on 2; RxJS turns the throw into an error notification. The subscriber gets next 1, then error bad 2. Value 3 is never emitted — of was unsubscribed the moment the error happened.
B: the error reaches catchError, which logs caught bad 2 and returns of(-1). RxJS subscribes to that replacement, so the subscriber sees -1 and then complete — the replacement's completion. It never sees the error. 3 is still lost: catching replaces the rest of the stream, it doesn't resume it.
C: returning EMPTY turns a failure into a quiet completion — useful when "no data" is an acceptable outcome, but don't use it to hide errors you should show the user.
D: returning throwError(() => ...) rethrows a new error. Use this to translate low-level failures (an HttpErrorResponse) into something meaningful for the UI. throwError takes a factory in RxJS 7, so the error object is created per subscriber.
E, F, G: finalize ran after complete, after error, and after an unsubscribe() where neither fired. Note the order: the subscriber's complete/error callback runs first, then finalize. That makes it the right place for "always" clean-up like this.loading.set(false).
AdvancedPlacing catchError in long-lived streams
Where you put catchError decides how much of the pipeline survives. In a long-lived stream (a search box, a polling loop), an error that reaches the outer level kills the whole stream; catching inside the inner request keeps the outer one alive. This is shown with the search-box scenario in switchMap — an outer catchError makes the search stop working after one failed request, an inner one doesn't.
Practical notes:
In Angular, HTTP failures arrive as an HttpErrorResponse in the error channel — see Error handling and retry for retry({ count, delay }) and status handling.
catchError placed beforeretry would hide the error from retry; put retry first, catchError last.
toSignal() rethrows a stream's error when you read the signal (verified: reading a toSignal(throwError(...)) signal threw boom), so a single failed request can break a template. Catch inside the pipe if the template should show a fallback.
?Where does 3 go?
JavaScript
of(1,2,3).pipe(map((x)=>{if(x ===2)thrownewError('two');return x;}),catchError(()=>of(99)),map((x)=> x *10),).subscribe({next:(v)=> console.log(v),complete:()=> console.log('complete')});
Show answer
10, 990, complete.
What’s happening
1 passes the first map, catchError doesn't interfere with normal values, and the last map gives 10.
2 makes the first map throw. The error travels down to catchError, which unsubscribes from the failed part (so of(1, 2, 3) stops) and subscribes to of(99).
99 flows on through the last map → 990. Operators aftercatchError keep working because they're now attached to the replacement.
of(99) completes → complete. 3 is never emitted: the original source is gone.
You rarely want subscribe() in a component, because then you own the unsubscribe. Angular gives you two consumers that manage the subscription for you. The async pipe subscribes when the template renders the expression, gives the template the latest value (or null before the first one), marks the view for check on each emission, and unsubscribes when the view is destroyed. toSignal(obs$) does the same job from the class: it subscribes immediately, exposes the latest value as a signal, and unsubscribes when the component (its injection context) is destroyed. Because the result is a signal, you can read it in computed(), in templates without a pipe, and as many times as you like — one subscription no matter how often it's read.
The other direction exists too: toObservable(signal) gives you an observable of a signal's values, so a signal (say, a search query) can feed RxJS operators like debounceTime and switchMap. My notes call the async pipe a "head twister"; chapter 5's The async pipe covers the pipe itself, and Signals don't need the async pipe why signals skip it.
user-signal.ts
@Component({
selector:'app-user-signal',
template:`
@if (user(); as user) {
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
} @else {
<p>Loading…</p>
}
`,})exportclassUserSignal{// Subscribes once, right now (we're in an injection context), and unsubscribes on destroy.
user =toSignal(inject(UserApi).getUser(1),{ initialValue:null});}
What’s happening
The field initializer runs during construction, which is an injection context, so both inject(UserApi) and toSignal(...) are allowed here. toSignal subscribes on the spot — the HTTP request is sent as soon as the component is created, not when the template first reads it.
Until the response arrives, the signal holds initialValue — the spec logged UserSignal initial value: null — so @if (user(); as user) shows Loading…. Without initialValue, the type would be Signal<User | undefined> and the value undefined.
The spec found exactly one request with http.expectOne('/api/users/1'), flushed { id: 1, name: 'Ada', … }, and the signal's value became that object; the DOM then read Ada and ada@example.com.
When the component is destroyed, toSignal unsubscribes via its DestroyRef — no ngOnDestroy needed. Reading user() twice in the template is still one subscription, unlike two | async pipes (Multiple subscriptions mean multiple executions).
Called outside an injection context (e.g. in a click handler), toSignal throws: NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. See Where toSignal() can run.
AdvancedtoSignal options and toObservable timing
toSignal options worth knowing, all verified against @angular/core/rxjs-interop in Angular 22:
initialValue — the value before the first emission; also sets the signal's type (initialValue: null → Signal<User | null>).
requireSync: true — for sources that emit synchronously on subscribe, like a BehaviorSubject; the type has no undefined. With a source that doesn't, it throws NG0601: `toSignal()` called with `requireSync` but `Observable` did not emit synchronously.
manualCleanup: true — don't tie the subscription to DestroyRef; it lasts until the observable completes.
Errors — if the observable errors, reading the signal throws that error.
Going the other way, toObservable has a timing rule that surprises people:
rx.spec.ts
it('toObservable emits only the latest value per effect run',async()=>{const count =signal(0);const seen:number[]=[];const count$ = TestBed.runInInjectionContext(()=>toObservable(count));
count$.subscribe((n)=> seen.push(n));// seen: [] right after subscribe
TestBed.tick();// seen: [0]
count.set(1);
count.set(2);
count.set(3);// seen: [0] before the next tick
TestBed.tick();// seen: [0, 3]expect(seen).toEqual([0,3]);});
What’s happening
toObservable(count) needs an injection context because it creates an effect under the hood; TestBed.runInInjectionContext provides one in a test.
Subscribing doesn't emit anything synchronously — seen is still []. The effect that reads count() and calls next() hasn't run yet; effects run during change detection (here, TestBed.tick()).
After the first tick(), the effect runs once and emits the current value → [0].
Three synchronous set calls don't emit three times. They just mark the effect dirty; when it runs at the next tick(), it reads the signal once and emits the latest value, 3. 1 and 2 are never seen.
That's by design — a signal is a value, not a stream of events. If you need every intermediate value, the source should be a Subject, not a signal.
When you genuinely need subscribe() in a component (calling a method on each value, not rendering it), pair it with takeUntilDestroyed() — covered in depth in takeUntilDestroyed. Verified here in a component spec:
ticks is a module-level Subject that outlives any component — the situation where forgetting to unsubscribe leaks.
In the constructor (an injection context), takeUntilDestroyed() injects the component's DestroyRef itself. The spec read ticks.observed as true while the component was alive, and ticks.next(7) set last() to 7.
fixture.destroy() fires the DestroyRef, takeUntilDestroyed completes the stream, and the subscription ends → ticks.observed became false.
Calling takeUntilDestroyed() with no argument in ngOnInit threw NG0203: takeUntilDestroyed() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. — lifecycle hooks are not injection contexts.
LateTickerFixed injects DestroyRef in a field (injection context) and passes it explicitly, which works anywhere. Put takeUntilDestroyedlast in the pipe, so it also tears down any operators before it.
Advancedasync pipe vs toSignal vs subscribe
async pipe
toSignal()
subscribe() + takeUntilDestroyed()
Subscribes
When the template renders it
Immediately, at creation
When you call it
Initial value
null
undefined or your initialValue
n/a
Reading it twice
Two pipes = two subscriptions
One subscription, read freely
n/a
Usable in computed()
No
Yes
No
Unsubscribes
View destroyed
DestroyRef (component destroyed)
DestroyRef
?Three sets, one tick
A component has query = signal('a') and query$ = toObservable(this.query) with a subscriber that logs each value. After the first change detection has run, a click handler does query.set('b'); query.set('c'); query.set('d');. What does the subscriber log after the next change detection?
Show answer
Just d (having logged a earlier).
What’s happening
toObservable is backed by an effect, and effects don't run synchronously when a signal changes — they're scheduled and run during change detection.
The three set calls happen synchronously in one handler; each just marks the effect dirty again.
When change detection runs, the effect reads query() once — it's 'd' — and calls next('d').
Verified in a component spec: after the click, seen was still ['a']; after await fixture.whenStable() it was ['a', 'd']. Signals give you the latest state, not every change.
My notes open this part with "Chalo bro, RxJS part 2 — the famous four flattening operators." Here's the problem all four solve. Often each value from one stream should start another stream: each search term starts an HTTP request, each click starts a save. With plain map you'd get an observable of observables (map, filter and tap). A flattening operator (also called a higher-order mapping operator) subscribes to each inner observable for you and merges its values into one output stream. switchMap, mergeMap, concatMap and exhaustMap all do that; they differ only in what happens when a new outer value arrives while an inner one is still running.
switchMap cancels the previous inner observable and switches to the new one. Only the latest request matters. Think of a TV remote: pressing a new channel doesn't queue it after the current programme, it switches immediately and you stop seeing the old one. That's exactly right for searching, autocomplete and "load the details of whatever's selected now".
All four operators in this chapter run on the same scenario, so you can compare them line by line: the user types a at 0ms, ab at 50ms and abc at 120ms, and each search request takes 100ms.
search-scenario.mjs
import{ merge, timer, map, tap }from'rxjs';const start = Date.now();constlog=(...args)=> console.log(`${String(Date.now()- start).padStart(3)}ms`,...args);// One value at a given time.constat=(ms, value)=>timer(ms).pipe(map(()=> value));// Keystrokes: "a" at 0ms, "ab" at 50ms, "abc" at 120ms.const searchBox$ =merge(at(0,'a'),at(50,'ab'),at(120,'abc')).pipe(tap((q)=>log(`typed "${q}"`)),);// A fake HTTP call: subscribing starts it, it answers after 100ms, unsubscribing cancels it.constfakeSearch=(q)=>timer(100).pipe(map(()=>`results for "${q}"`),tap({subscribe:()=>log(` request "${q}" started`),unsubscribe:()=>log(` request "${q}" cancelled`),}),);const print ={next:(r)=>log('→', r),complete:()=>log('complete')};
What’s happening
at(ms, value) is a one-value observable: timer(ms) emits once after ms, and map swaps that emission for value.
merge(...) subscribes to all three at once and forwards whatever each emits, so searchBox$ emits a, ab, abc at 0, 50 and 120ms, then completes. It stands in for an input's value changes.
fakeSearch(q) behaves like http.get(...): cold, nothing happens until subscribed, one result after 100ms, then complete.
tap({ subscribe, unsubscribe }) is RxJS 7's lifecycle form of tap: subscribe fires when the request is started, and unsubscribe fires only if it's cancelled before it finishes (not when it completes normally). That's what makes cancellation visible in the logs.
Run each of the four operator examples on its own, so their logs don't interleave.
0ms — a arrives. switchMap calls fakeSearch('a') and subscribes to it → request a starts (due at 100ms).
50ms — ab arrives while a is in flight. switchMap first unsubscribes from the a request (request "a" cancelled), then subscribes to fakeSearch('ab') (due at 150ms). The order matters: cancel old, then start new.
120ms — abc arrives; the same thing happens to ab, which was 30ms from finishing. abc starts, due at 220ms.
220ms — abc answers → results for "abc", the only result the subscriber ever sees. Results for a and ab can't arrive out of order and overwrite newer ones, because they were cancelled.
220ms — complete. The outer stream completed at 120ms, but a flattening operator only completes when the outer and the active inner are both done.
As a marble string (one character per 10ms, b = ab, c = abc, (c|) = emit and complete in the same frame): input a····b······(c|), output ······················(c|). Compare the same scenario under mergeMap, concatMap and exhaustMap, or all four in the diagram in Choosing a flattening operator.
AdvancedWhat switchMap does not cancel
switchMap doesn't magically kill everything
My notes have this as a separate heading, and it's the most important nuance. switchMap cancels by unsubscribing from the previous inner observable. What that actually stops depends entirely on what the inner observable's teardown does. Four cases where people expect more than they get:
1. A promise inside switchMap is not cancelled — the work still finishes.
switchmap-promise.mjs
// A promise-based "fetch": starts immediately and cannot be stopped.constpromiseSearch=(q)=>newPromise((resolve)=>{log(` server starts "${q}"`);setTimeout(()=>{log(` server finished "${q}"`);resolve(`results for "${q}"`);},100);});
searchBox$.pipe(switchMap((q)=>promiseSearch(q))).subscribe(print);// 0ms typed "a"// 0ms server starts "a"// 50ms typed "ab"// 50ms server starts "ab"// 100ms server finished "a"// 120ms typed "abc"// 120ms server starts "abc"// 150ms server finished "ab"// 220ms server finished "abc"// 220ms → results for "abc"// 220ms complete
What’s happening
switchMap accepts a promise as the inner value (it's converted with from()). The output is still correct: only results for "abc" reaches the subscriber.
But look at the server lines: a and abran to completion at 100 and 150ms. A promise has no cancel — unsubscribing from from(promise) only means "ignore the result".
So switchMap "cancelled" them only in the sense that their results were dropped. The network traffic and server work happened anyway.
Fix: use something cancellable as the inner — HttpClient (which aborts the request on unsubscribe, see case 4) or an observable wrapping fetch with an AbortController in its teardown. (These timings are from real timers; the order is stable across runs.)
2. Subscriptions you make yourself inside the callback are not managed.
switchmap-nested.mjs
searchBox$
.pipe(switchMap((q)=>{fakeSearch(q).subscribe((r)=>log(' nested subscribe still got', r));// ❌ not managed by switchMapreturnof(q);}),).subscribe((q)=>log('switchMap emitted', q));// 0ms typed "a"// 0ms request "a" started// 0ms switchMap emitted a// 50ms typed "ab"// 50ms request "ab" started// 50ms switchMap emitted ab// 100ms nested subscribe still got results for "a"// 120ms typed "abc"// 120ms request "abc" started// 120ms switchMap emitted abc// 150ms nested subscribe still got results for "ab"// 220ms nested subscribe still got results for "abc"
What’s happening
switchMap only knows about the observable you return — here of(q), which emits and completes instantly.
The fakeSearch(q).subscribe(...) inside is an ordinary manual subscription. switchMap never sees it, so it can never cancel it: there isn't a single cancelled line.
All three requests complete and all three nested callbacks fire, out of the user's sight — the "stale results overwrite fresh ones" bug comes straight back.
Rule: never subscribe inside an operator. Return the inner observable and let the flattening operator subscribe to it.
3. An error in the inner request kills the outer stream — unless you catch it inside.
switchmap-errors.mjs
constfailing=(q)=>
q ==='ab'?timer(30).pipe(map(()=>{thrownewError(`500 for "${q}"`);})):fakeSearch(q);// catchError OUTSIDE switchMap
searchBox$
.pipe(switchMap(failing),catchError(()=>{log('outer catchError');returnEMPTY;})).subscribe(print);// 0ms typed "a"// 0ms request "a" started// 50ms typed "ab"// 50ms request "a" cancelled// 80ms outer catchError// 80ms complete// catchError INSIDE switchMap, on the inner request
searchBox$
.pipe(switchMap((q)=>failing(q).pipe(catchError((e)=>{log('inner catchError:', e.message);returnof(`no results for "${q}"`);})),),).subscribe(print);// 0ms typed "a"// 0ms request "a" started// 50ms typed "ab"// 50ms request "a" cancelled// 80ms inner catchError: 500 for "ab"// 80ms → no results for "ab"// 120ms typed "abc"// 120ms request "abc" started// 220ms → results for "abc"// 220ms complete
What’s happening
failing('ab') errors 30ms after it starts (at 80ms); the other terms behave normally.
Outer catch: the inner error propagates out through switchMap, which errors — and an errored stream is finished. catchError replaces the whole pipeline with EMPTY → complete at 80ms. Notice there's no typed "abc" line at 120ms: the search box itself was unsubscribed. In an app, search silently stops working after one 500.
Inner catch: the error is caught on the inner observable, before it reaches switchMap. From switchMap's point of view, the ab request simply emitted no results for "ab" and completed. The outer stream never saw an error.
So at 120ms abc is still typed, still searched, and still answered at 220ms. The pipeline survives any number of failed requests.
Rule for every long-lived stream: put catchErrorinside the flattening operator's callback.
4. HttpClient cancels the request in the browser — not on the server.HttpClient's teardown aborts the underlying request, and Angular's testing backend lets you observe it. This is the typeahead from debounceTime and distinctUntilChanged, and its spec checks the first request's cancelled flag:
The first search (q=rx) is sent and left pending — the test deliberately doesn't flush it.
A new query reaches switchMap 300ms later, so switchMap unsubscribes from the first HttpClient observable.
HttpClient's teardown cancels the request, and HttpTestingController records it: first.cancelled is true, second.cancelled is false. In a browser, that unsubscribe aborts the XHR/fetch, and DevTools shows the request as "(canceled)".
But the request had already left the browser. The server may still process it, and for a POST/PUT the change may already be saved. Cancelling only stops you from waiting for the answer.
That's why switchMap is for reads. For writes, a cancelled save can still land, or (worse) land after a later one — use concatMap for saves, exhaustMap for submit buttons.
A typeahead shouldn't fire a request on every keystroke. debounceTime(ms) waits until the source has been quiet for ms, then emits the latest value: each new value restarts the countdown, so a burst of typing produces one emission, ms after the last key. distinctUntilChanged() drops a value if it's equal (===) to the previous emitted one — so if the debounced text comes out the same as last time (type a letter, delete it, pause), no new request is made.
Analogy for debounceTime: a lift door. Every person who walks in resets the door timer; the lift leaves only once nobody has come in for a few seconds. distinctUntilChanged is a doorman who says "you're the same person who just left, no need to go again".
debounce.mjs
import{NEVER, merge, timer, map, tap, debounceTime, distinctUntilChanged }from'rxjs';// r, rx, rxj typed quickly; a pause; then "rxjs", and a backspace back to "rxj".const input$ =merge(at(0,'r'),at(80,'rx'),at(160,'rxj'),at(600,'rxjs'),at(700,'rxj'),NEVER).pipe(tap((v)=>log(`input "${v}"`)),);
input$
.pipe(debounceTime(300),tap((v)=>log(`debounced "${v}"`)),distinctUntilChanged(),).subscribe((v)=>log(`distinct "${v}"`));// 0ms input "r"// 80ms input "rx"// 160ms input "rxj"// 460ms debounced "rxj"// 460ms distinct "rxj"// 600ms input "rxjs"// 700ms input "rxj"// 1000ms debounced "rxj"
What’s happening
NEVER in the merge keeps input$ from completing, like a real input's value stream (why that matters is in the gotcha below).
r at 0ms starts a 300ms countdown; rx at 80ms restarts it; rxj at 160ms restarts it again. Nothing more arrives, so at 160 + 300 = 460msdebounceTime emits the latest value, rxj. Three keystrokes, one emission.
distinctUntilChanged has no previous value yet, so rxj passes → distinct "rxj" (this is where the request would go).
rxjs at 600ms starts a countdown; the backspace to rxj at 700ms restarts it. At 1000msdebounceTime emits rxj — the intermediate rxjs was never emitted at all.
distinctUntilChanged compares rxj with the last value it let through, rxj → equal → dropped. No distinct line at 1000ms: the search that's already on screen isn't repeated.
If you swapped the order (distinctUntilChanged before debounceTime) it would compare raw keystrokes, which are almost never consecutive duplicates — it would filter nothing useful here.
AdvancedEdge case: source completes early
AdvanceddistinctUntilChanged with objects
distinctUntilChanged() compares with ===, which matters for objects:
distinct-objects.mjs
merge(at(0,{q:'rx'}),at(10,{q:'rx'}),at(20,{q:'rxjs'})).pipe(distinctUntilChanged()).subscribe((v)=>log('by reference:', v.q));// 0ms by reference: rx// 10ms by reference: rx// 20ms by reference: rxjsmerge(at(100,{q:'rx'}),at(110,{q:'rx'}),at(120,{q:'rxjs'})).pipe(distinctUntilChanged((a, b)=> a.q === b.q)).subscribe((v)=>log('by comparator:', v.q));// 100ms by comparator: rx// 120ms by comparator: rxjs
What’s happening
Two separate { q: 'rx' } literals are different objects, so === says they differ and both pass — the duplicate isn't caught.
Passing a comparator (a, b) => a.q === b.q compares what you actually care about; the second rx at 110ms is dropped.
For a single key there's also distinctUntilKeyChanged('q'). For a form's valueChanges, which emits a fresh object each time, you need one of these — plain distinctUntilChanged() does nothing.
AdvancedFull typeahead with signals and HttpClient
The whole typeahead in Angular 22, with a signal for the input, verified in a component spec with fake timers and HttpTestingController:
The input writes into the query signal on every keystroke. toObservable(this.query) turns that state into a stream (one emission per change-detection pass, latest value only).
map(trim) normalises; debounceTime(300) waits for a pause; distinctUntilChanged() skips a repeat of the last search; filter(length >= 2) skips too-short terms. The order matters: distinct runs on the trimmed, debounced text.
switchMap sends this.api.search(text) (an HttpClient GET with ?q=) and cancels the previous one if the user searches again. catchError(() => of([])) sits inside the switchMap, so a failed request shows no results instead of killing the typeahead.
toSignal(..., { initialValue: [] }) subscribes once, for the component's lifetime, and gives the template a plain string[] signal.
The spec set query to rx: after 299ms there were 0 requests; at 300ms exactly one, /api/search?q=rx. Setting rxjs 300ms later sent /api/search?q=rxjs and cancelled the first (first.cancelled === true). Flushing ['rxjs', 'rxjs-interop'] rendered two <li>s.
mergeMap runs every inner observable at the same time and merges their results as they arrive. Nothing is cancelled, nothing waits. (flatMap is its old, deprecated alias.) Think of a team of waiters taking orders: each new table gets served in parallel, and dishes come out in whatever order the kitchen finishes them.
That makes it right when every inner result matters and order doesn't: deleting several items, uploading several files, fetching details for each id in a list, handling independent messages. It's wrong for a typeahead (stale results can arrive last) and for writes to the same resource (they can be applied out of order).
mergemap.mjs
searchBox$.pipe(mergeMap((q)=>fakeSearch(q))).subscribe(print);// 0ms typed "a"// 0ms request "a" started// 50ms typed "ab"// 50ms request "ab" started// 100ms → results for "a"// 120ms typed "abc"// 120ms request "abc" started// 150ms → results for "ab"// 220ms → results for "abc"// 220ms complete
What’s happening
0msa → request a starts (due 100ms). 50msab → request ab starts too; a keeps running. Two requests are in flight at once.
100msa answers → results for "a". 120msabc starts (due 220ms) — now ab and abc overlap.
150msab answers; 220msabc answers. Three requests, three results, no cancelled lines.
The results came out in a, ab, abc order only because every request took the same 100ms. They're emitted in completion order, not request order — next example.
220mscomplete: the outer completed at 120ms, and mergeMap waited for all active inners.
AdvancedmergeMap result order by duration
When durations differ, the order follows the durations:
mergemap-order.mjs
constjob=(name, ms)=>timer(ms).pipe(map(()=> name),tap({subscribe:()=>log(` start ${name} (${ms}ms)`)}));const durations ={a:150,ab:30};merge(at(0,'a'),at(50,'ab')).pipe(mergeMap((q)=>job(q, durations[q]))).subscribe((r)=>log('→', r));// 0ms start a (150ms)// 50ms start ab (30ms)// 80ms → ab// 150ms → a
What’s happening
a starts at 0ms and is slow (150ms) — due at 150ms.
ab starts at 50ms and is fast (30ms) — due at 80ms.
ab finishes first and is emitted first; a arrives at 150ms. In a search box this would replace the fresh ab results with the stale a results — exactly the bug switchMap prevents.
AdvancedLimiting parallel work with concurrent
mergeMap has a second argument, concurrent, which caps how many inners run at once; the rest wait in a queue:
My notes: "concatMap (order matters!!!)". concatMap runs inner observables one at a time, in arrival order. A new outer value waits in a queue until the current inner completes; nothing is cancelled and nothing overlaps. It's a single checkout till: everyone gets served, strictly in the order they joined the queue.
It's the safe default for writes where order matters: autosave, sequential PUTs to the same resource, steps that depend on the previous one finishing, an animation sequence.
concatmap.mjs
searchBox$.pipe(concatMap((q)=>fakeSearch(q))).subscribe(print);// 0ms typed "a"// 0ms request "a" started// 50ms typed "ab"// 100ms → results for "a"// 100ms request "ab" started// 120ms typed "abc"// 200ms → results for "ab"// 200ms request "abc" started// 300ms → results for "abc"// 300ms complete
What’s happening
0msa starts. 50msab arrives but a is still running, so ab is queued — note there's no request "ab" started yet.
100msa completes → its result is emitted, and only then does concatMap start ab.
120msabc arrives and joins the queue behind ab.
200msab completes → result, then abc starts. 300msabc completes → result, then complete.
All three results, in request order, but the last one arrives at 300ms (vs 220ms for switchMap/mergeMap). For a search box that's wasted time spent on stale queries; for saves it's exactly the guarantee you want.
AdvancedOrdered autosave writes with concatMap
Why it matters for writes — an autosave where version 1 is a big payload (100ms) and version 2, typed 10ms later, a small one (30ms):
autosave.mjs
let serverState ='v0';const durations ={v1:100,v2:30};constsave=(v)=>timer(durations[v]).pipe(tap({subscribe:()=>log(` PUT ${v} sent`)}),tap(()=>{ serverState = v;log(` PUT ${v} done → server has ${v}`);}),);merge(at(0,'v1'),at(10,'v2')).pipe(mergeMap(save))// then try concatMap(save).subscribe({complete:()=>log(`final server state: ${serverState}`)});// with mergeMap:// 0ms PUT v1 sent// 10ms PUT v2 sent// 40ms PUT v2 done → server has v2// 100ms PUT v1 done → server has v1// 100ms final server state: v1// with concatMap:// 0ms PUT v1 sent// 100ms PUT v1 done → server has v1// 100ms PUT v2 sent// 130ms PUT v2 done → server has v2// 130ms final server state: v2
What’s happening
save(v) simulates a PUT that takes durations[v] ms and then sets serverState to v.
With mergeMap, both PUTs are in flight together. The small v2 finishes first (40ms) and the big v1 finishes last (100ms), so the server ends with the older version v1. The user's latest edit is silently lost.
With concatMap, v2 isn't sent until v1 is done (100ms), so the writes land in the order they were made and the server ends with v2.
switchMap would be wrong too: it would cancel v1 client-side, but v1 may already have reached the server — and you'd have no idea whether it was applied (switchMap doesn't magically kill everything).
My notes: "exhaustMap (wait until I am done)". exhaustMap ignores new outer values while an inner observable is running. No cancelling, no queue: while it's busy, incoming values are simply dropped. Once the inner completes, the next value to arrive starts a new one. Picture a lift that's already moving: pressing the call button again does nothing until it's back.
It's the right tool when a repeated trigger should not start the same work again: a login or submit button that gets double-clicked, a "refresh" button, a pull-to-refresh gesture.
exhaustmap.mjs
searchBox$.pipe(exhaustMap((q)=>fakeSearch(q))).subscribe(print);// 0ms typed "a"// 0ms request "a" started// 50ms typed "ab"// 100ms → results for "a"// 120ms typed "abc"// 120ms request "abc" started// 220ms → results for "abc"// 220ms complete
What’s happening
0msa starts (busy until 100ms).
50msab arrives while busy → dropped. It's not queued: there is no request "ab" started, ever.
100msa completes → results for "a". exhaustMap is idle again.
120msabc arrives while idle → it starts, and answers at 220ms. Had abc arrived at 90ms instead, it would have been dropped too, and the user's final term would never be searched — which is why exhaustMap is wrong for search.
AdvancedIgnoring double-clicked logins with exhaustMap
The use case it's built for — a double-clicked login button:
The first click at 0ms starts a 200ms login request.
The impatient clicks at 30 and 60ms arrive while it's in flight and are ignored: one request, not three.
At 200ms the login completes → logged in; exhaustMap is idle again.
A deliberate click at 250ms starts a fresh request. With mergeMap you'd have sent three logins at once; with switchMap you'd have cancelled two and maybe still created two sessions server-side.
In Angular this is fromEvent(button, 'click') or a Subject you next() from the click handler — the operator is the same.
b arrives at 50ms while a is still running → ignored entirely. No b values, ever.
The outer completed at 50ms and the only inner completes at 60ms → complete at 60ms.
Lined up against the other three quizzes: switchMap → a0 a1 b0 b1 b2, mergeMap → a0 a1 a2 b0 b1 b2, concatMap → same values with b 10ms later, exhaustMap → a0 a1 a2.
All four take the same arguments and subscribe to the inner observable for you. The only question is: a new value arrives while the previous inner is still running — what should happen to the old one and the new one?
Login/submit buttons, refresh, polling that mustn't overlap
The search-box scenario from this chapter, all four side by side — input at 0/50/120ms, each request 100ms:
Same keystrokes, same 100ms requests: only what happens to the overlapping request changes.
Reading the diagram against the real runs:
switchMap — two red (cancelled) bars and one result, abc at 220ms. Fastest correct answer for a search, no stale results.
mergeMap — three overlapping green bars, three results at 100, 150 and 220ms, in completion order.
concatMap — dashed segments are time spent waiting in the queue; results at 100, 200 and 300ms, strictly in order.
exhaustMap — ab never started; results at 100 and 220ms.
A quick way to decide, in the order I'd ask the questions:
Is it a write (save, delete, payment)? Never switchMap. If order matters → concatMap; if they're independent → mergeMap; if a duplicate trigger should be ignored → exhaustMap.
Is it a read where only the latest input matters? → switchMap.
Must every result arrive and order doesn't matter? → mergeMap, with a concurrent limit if there could be many.
Should a new trigger be ignored while busy? → exhaustMap.
?Pick the operator
For each, which flattening operator? (1) Every time the route's :id changes, load that product. (2) A "Pay now" button. (3) The user reorders a list; each drop sends PUT /order. (4) Delete the 20 selected emails.
Show answer
(1) switchMap, (2) exhaustMap, (3) concatMap, (4) mergeMap (ideally with a concurrent limit).
What’s happening
Route id → product: only the current id matters; if the user navigates on before the old product loads, cancel it. A read, latest wins → switchMap.
Pay now: a second click while the first payment is in flight must not start another payment. Not cancel (the first may already be charging), not queue (that would charge twice) → exhaustMap.
Reorder: every PUT matters and the last one must land last, or the server ends with an older order → concatMap.
Delete 20: independent, all must happen, order irrelevant → mergeMap(deleteOne, 4) to avoid 20 simultaneous requests.
A Subject is an observable you can push values into by hand. It's both sides at once: an Observer (it has next, error and complete) and an Observable (you can subscribe to it). Internally it's just a list of subscribers; subject.next(v) loops over the list and calls each one's next(v).
My notes: "Subject is hot!!!" That's the key fact. A Subject doesn't run a recipe per subscriber — there's one shared stream, and a value is delivered only to whoever is subscribed at the moment next() is called. Values sent before you subscribed are gone. It's a radio broadcast, not a podcast. That makes Subjects the natural tool for events — "the user clicked save", "a message arrived", "the form was reset" — and the building block that share() uses to make cold observables hot (Hot vs cold observables).
next('lost…') runs with an empty subscriber list, so it goes nowhere. A Subject has no memory: nothing is buffered for later.
A subscribes (list: [A]), then next(1) → A got 1.
B subscribes (list: [A, B]). B does not get 1 — it already went by. next(2) reaches both → A got 2, B got 2.
complete() completes everyone and closes the Subject. C subscribing afterwards gets an immediate complete and no values. Any further next() would be ignored.
Compare with a cold observable, where B would have started its own execution from the beginning. Late subscribers to a Subject miss values; that's what "hot" means.
AdvancedMulticasting a cold source through a Subject
A Subject is also an Observer, so you can pass it to subscribe() — the source runs once, and the Subject fans each value out to all of its subscribers:
subject-multicast.mjs
import{ Observable, Subject }from'rxjs';let runs =0;const cold$ =newObservable((s)=>{
runs++;
s.next(`run ${runs}`);
s.complete();});const hub =newSubject();
hub.subscribe((v)=> console.log('X got', v));
hub.subscribe((v)=> console.log('Y got', v));
cold$.subscribe(hub);// a Subject is also an Observer
console.log('cold$ executions:', runs);// X got run 1// Y got run 1// cold$ executions: 1
What’s happening
X and Y subscribe to the Subject first, so they're in its list before any value is sent.
cold$.subscribe(hub) passes the Subject as the observer: cold$ runs once, and each s.next(...) calls hub.next(...), which forwards it to both X and Y.
One execution, two receivers. This is exactly how share() works inside: a Subject subscribed to the source, with your subscribers subscribed to the Subject.
If X and Y subscribed aftercold$.subscribe(hub), they'd get nothing: the value and the completion had already gone through.
AdvancedThe Subject family and late subscribers
The Subject family, each answering "what does a late subscriber get?" differently:
ReplaySubject(2) keeps a buffer of the last two values. 1, 2, 3 were sent before anyone subscribed; the buffer holds [2, 3], so the late subscriber gets those two immediately, synchronously, on subscribe.
ReplaySubject(1) is what shareReplay(1) uses inside — that's where its cached value comes from.
AsyncSubject delivers nothing on next(); it remembers only the latest value. On complete() it emits that last value (2) and completes — similar to how a promise gives you one final result.
AdvancedSubjects in Angular code
How Subjects show up in Angular code:
Event streams from imperative code — a click handler calls this.refresh$.next(), and a pipeline does refresh$.pipe(switchMap(() => this.api.load())). That's how you get a signal-free "trigger" into RxJS.
Expose read-only — keep the Subject private and expose subject.asObservable(), so outside code can subscribe but not call next() (the type has no next; verified in the store spec: 'next' in store.items$ is false).
Old-style takeUntil(destroy$) — a destroy$ = new Subject<void>() completed in ngOnDestroy; modern code uses takeUntilDestroyed() instead (takeUntilDestroyed).
Don't use a Subject when a signal will do. For "a value that changes", a signal() is simpler and glitch-free (Signals and RxJS together). Reach for a Subject when you have events — things that happen, possibly with the same value twice — and want RxJS operators on them.
AdvancedPitfall: emitting before anyone subscribes
A BehaviorSubject is a Subject that always has a current value. You must give it one when you create it, next() replaces it, every new subscriber immediately receives the current value, and you can read it synchronously with .value. Where a Subject is a broadcast of events, a BehaviorSubject is a piece of state: "the current user", "the items in the cart", "is the sidebar open" — things that always have a value, which a newcomer should see right away.
Subject vs BehaviorSubject, side by side with a late subscriber:
behaviorsubject.mjs
import{ BehaviorSubject }from'rxjs';const bs =newBehaviorSubject('initial');
bs.subscribe((v)=> console.log('A got', v));
bs.next(1);
bs.next(2);
bs.subscribe((v)=> console.log('B (late) got', v));
console.log('bs.value =', bs.value);
bs.next(3);// A got initial// A got 1// A got 2// B (late) got 2// bs.value = 2// A got 3// B (late) got 3
What’s happening
new BehaviorSubject('initial') stores 'initial' as the current value. A subscribes and immediately, synchronously, gets initial — before any next() has been called.
next(1) and next(2) update the current value and reach A each time.
B subscribes late. Unlike with a plain Subject, it isn't left empty: it immediately gets the current value, 2. It does not get initial or 1 — a BehaviorSubject keeps exactly one value, not a history.
bs.value reads the current value synchronously → 2. That's handy inside services, e.g. next([...this.items$$.value, item]).
next(3) reaches both. From here on A and B are in lockstep.
AdvancedClassic BehaviorSubject cart store
My notes call this "traditional Angular shared state using BehaviorSubject" — the standard pattern in Angular ≤ 16 code: a service owns a private BehaviorSubject and exposes it as a read-only observable, plus methods that compute the next state.
cart-store.ts
// The traditional (pre-signals) way: a private BehaviorSubject, exposed read-only.@Service()exportclassCartStore{privatereadonly items$$ =newBehaviorSubject<CartItem[]>([]);readonly items$ =this.items$$.asObservable();readonly total$ =this.items$.pipe(map((items)=> items.reduce((sum, i)=> sum + i.price,0)));add(item: CartItem){this.items$$.next([...this.items$$.value, item]);}clear(){this.items$$.next([]);}}// The same store with signals (Angular 16+).@Service()exportclassCartSignalStore{privatereadonly _items =signal<CartItem[]>([]);readonly items =this._items.asReadonly();readonly total =computed(()=>this._items().reduce((sum, i)=> sum + i.price,0));add(item: CartItem){this._items.update((items)=>[...items, item]);}}
What’s happening
items$$ (the double $$ is a common convention for "the writable Subject behind items$") starts as [] and is private, so only the store can change state.
items$ = items$$.asObservable() is what components see: an Observable with no next. The spec checked 'next' in store.items$ → false, so a component can't bypass add().
add() reads the current value synchronously with .value, builds a new array, and next()s it. After adding a Book (20) and a Pen (5), a subscriber that arrived later still got the full state: firstValueFrom(store.items$) gave both items and total$ gave 25.
Components consume it with store.items$ | async or toSignal(store.items$); toSignal(store.items$, { requireSync: true }) works here because a BehaviorSubject emits synchronously on subscribe.
CartSignalStore is the modern equivalent: private signal, public asReadonly(), derived computed(), same immutable update. It also gave total() === 25, with no subscriptions to manage and no async pipe. See Sharing state with signal-based services.
AdvancedPitfall: re-emitting a mutated array
AdvancedBehaviorSubject details and alternatives
Practical details:
.value after complete() still returns the last value (verified), and new subscribers just get complete. After error(), reading .valuethrows the error (verified: kaput).
BehaviorSubject vs signal: in new Angular 22 code, a signal is the default for component and service state — synchronous reads, automatic change detection, computed for derived state. Use a BehaviorSubject when the state needs to feed RxJS pipelines heavily, or you're working in an existing RxJS-based codebase. Converting is one call either way: toSignal(bs$, { requireSync: true }) or toObservable(sig).
BehaviorSubject vs ReplaySubject(1): both give a late subscriber the latest value, but ReplaySubject(1) has no value until the first next() (so late subscribers wait), and has no .value. Use it when there's no sensible initial value.
new BehaviorSubject(1) then next(2) → current value 2. No subscribers yet, but unlike a Subject the value isn't lost — it's stored.
before prints.
Subscribing delivers the current value synchronously, inside subscribe() → got 2; take(1) then completes and unsubscribes.
after prints last. This synchronous first emission is why toSignal(bs, { requireSync: true }) is allowed for a BehaviorSubject, and why firstValueFrom(store.items$) resolves with the current state straight away.