Skip to content

Barriers to Conceptual Understanding

The first barrier to RxJS is understanding concepts. Especially for developers accustomed to Promise, Observable's behavior can be counter-intuitive.

Essential Differences Between Observable vs Promise

Promise: One-Shot Asynchronous Processing

typescript
// Promise: return value only once
const userPromise = fetch('/api/user/1').then(res => res.json());

userPromise.then(user => console.log(user)); // fetch user information only once
userPromise.then(user => console.log(user)); // same result cached

Features

  • Eager (immediate execution) - processing starts at the point of Promise creation
  • Completes only once - only once, either success or failure
  • Not Cancelable - once started, cannot be stopped
  • Always Hot - multiple then share the same result

Observable: Stream (Flow of Data with Time Axis)

typescript
import { Observable } from 'rxjs';

// Observable: multiple values flowing along a time axis
const user$ = new Observable<User>(subscriber => {
  console.log('Observable execution started!');
  fetch('/api/user/1')
    .then(res => res.json())
    .then(user => {
      subscriber.next(user);
      subscriber.complete();
    });
});

// ❌ Nothing happens yet at this point (Lazy)
console.log('Observable creation complete');

// ✅ Only after subscribe is it executed
user$.subscribe(user => console.log('Subscribe 1:', user));
user$.subscribe(user => console.log('Subscribe 2:', user));
// → API call is executed twice (Cold Observable)

Output

Observable creation complete
Observable execution started!
Subscribe 1: { id: 1, name: 'Alice' }
Observable execution started!
Subscribe 2: { id: 1, name: 'Alice' }

Features

  • Lazy (delayed execution) - nothing happens until subscribe
  • Multiple values can be passed - next() can be called multiple times
  • Cancelable - can be stopped by unsubscribe
  • Cold or Hot - can choose to run per subscription or share

Comparison Chart

FeaturePromiseObservable
Execution timingImmediately (Eager)When subscribed (Lazy)
Number of ValuesOnce Only0 or More (multiple)
CancelNot allowedPossible (unsubscribe)
ReuseCached resultsRerun per subscription (Cold)
After ErrorTerminatedTerminated (retry possible)

Visualize Differences in Behavior

The following sequence diagram shows the difference in execution flow between Promise and Observable.

Common Misconceptions

❌ Misconception 1: "Observable is asynchronous-only"

typescript
// Observable can also handle synchronous processing
import { of } from 'rxjs';

const sync$ = of(1, 2, 3);

console.log('Before subscribe');
sync$.subscribe(value => console.log(value));
console.log('After subscribe');

// Output (executed synchronously):
// Before subscribe
// 1
// 2
// 3
// After subscribe

❌ Misconception 2: "subscribe returns a value"

typescript
import { map, of } from "rxjs";

const observable$ = of(1, 2, 3);

// ❌ Bad example: Promise-like thinking
const value = observable$.subscribe(x => x); // value is Subscription object
console.log(value); // Subscription { ... } ← not the expected value

// ✅ Good example: Observable-like thinking
observable$.pipe(
  map(x => x * 2)
).subscribe(value => {
  console.log(value); // use value here
});

Intuitive Understanding of Cold vs Hot

Cold Observable: Independent Streams Per Subscription

typescript
import { interval } from 'rxjs';
import { take } from 'rxjs';

// Cold: each subscriber has an independent timer
const cold$ = interval(1000).pipe(take(3));

console.log('Subscription 1 started');
cold$.subscribe(x => console.log('Subscription 1:', x));

setTimeout(() => {
  console.log('Subscription 2 started (2 seconds later)');
  cold$.subscribe(x => console.log('Subscription 2:', x));
}, 2000);

// Output:
// Subscription 1 started
// Subscription 1: 0        (after 1 second)
// Subscription 1: 1        (after 2 seconds)
// Subscription 2 started (after 2 seconds)
// Subscription 1: 2        (after 3 seconds)
// Subscription 2: 0        (after 3 seconds) ← Subscription 2 starts from the beginning
// Subscription 2: 1        (after 4 seconds)
// Subscription 2: 2        (after 5 seconds)

Cold Features

  • Independent execution per subscription
  • Keeps a "blueprint" of the data
  • Examples: HTTP API calls, timers, file reads

Hot Observable: All Subscribers Share the Same Stream

typescript
import { interval } from 'rxjs';
import { take, share } from 'rxjs';

// Hot: shared by share()
const hot$ = interval(1000).pipe(
  take(3),
  share() // this makes it Hot
);

console.log('Subscription 1 started');
hot$.subscribe(x => console.log('Subscribe 1:', x));

setTimeout(() => {
  console.log('Subscription 2 started (2 seconds later)');
  hot$.subscribe(x => console.log('Subscribe 2:', x));
}, 2000);

// Output:
// Subscription 1 started
// Subscribe 1: 0        (after 1 second)
// Subscribe 1: 1        (after 2 seconds)
// Subscription 2 started (2 seconds later)
// Subscribe 1: 2        (after 3 seconds)
// Subscribe 2: 2        (after 3 seconds) ← Subscription 2 joins in the middle

Hot Features

  • All subscribers share the same execution
  • Data is being "broadcast"
  • Examples: Click Event, WebSocket, Subject

How to Identify Cold/Hot

typescript
import { fromEvent, interval, of, Subject } from 'rxjs';
import { share } from 'rxjs';

// Cold (independent execution per subscription)
const cold1$ = of(1, 2, 3);
const cold2$ = interval(1000);
const cold3$ = ajax('/api/data');
const cold4$ = fromEvent(button, 'click'); // Cold but special

// Hot (shared among subscribers)
const hot1$ = new Subject<number>();
const hot2$ = interval(1000).pipe(share()); // convert Cold to Hot

How to Tell Them Apart

  • Creation Functions (of, from, fromEvent, interval, ajax, etc.) → Cold
  • Subject-based → Hot
  • share(), shareReplay() use → Convert Cold to Hot

Shift in Thinking to Declarative Programming

Imperative vs. Declarative

RxJS is a paradigm of declarative programming.

❌ Imperative Thinking (Promise/async-await)

typescript
// Imperative: describe "how" to process
async function processUsers() {
  const response = await fetch('/api/users');
  const users = await response.json();

  const activeUsers = [];
  for (const user of users) {
    if (user.isActive) {
      activeUsers.push(user);
    }
  }

  const userNames = [];
  for (const user of activeUsers) {
    userNames.push(user.name.toUpperCase());
  }

  return userNames;
}

✅ Declarative Thinking (RxJS)

typescript
import { from } from 'rxjs';
import { mergeMap, filter, map, toArray } from 'rxjs';

// Declarative: describe "what" to convert
const processUsers$ = from(fetch('/api/users')).pipe(
  mergeMap(res => res.json()),
  mergeMap(users => users), // expand array
  filter(user => user.isActive),
  map(user => user.name.toUpperCase()),
  toArray()
);

processUsers$.subscribe(userNames => console.log(userNames));

Differences

  • Imperative: Describes a procedure (loop, conditional branch, variable assignment)
  • Declarative: Describes a pipeline of conversions (data flow)

Key Points of Thought Transformation

Point 1: No Data Processing Within Subscribe

Data transformation should be done within pipe, and subscribe should be side effects only.

typescript
import { filter, map, of } from "rxjs";

const observable$ = of(1, 2, 3);
// ❌ Bad example: processing in subscribe
observable$.subscribe(value => {
  const doubled = value * 2;           // 👈 compute within subscribe
  const filtered = doubled > 4 ? doubled : null;  // 👈 conditional in subscribe
  if (filtered) {                      // 👈 if statement within subscribe
    console.log(filtered);
  }
});

// ✅ Good example: conversion within pipe
observable$.pipe(
  map(value => value * 2),       // calculation is done in pipe
  filter(value => value > 4)     // filtering also in pipe
).subscribe(value => console.log(value));  // subscribe is only a side effect

Point 2: Do Not Use Intermediate Variables

typescript
import { filter, map, Observable, of } from "rxjs";

const source$ = of(1, 2, 3, 4, 5);

// ❌ Bad example: store in intermediate variable
let doubled$: Observable<number>;      // 👈 declare intermediate variable
let filtered$: Observable<number>;     // 👈 declare intermediate variable

doubled$ = source$.pipe(map(x => x * 2));    // 👈 assign to intermediate variable
filtered$ = doubled$.pipe(filter(x => x > 5)); // 👈 assign to intermediate variable
filtered$.subscribe(console.log);

// ✅ Good example: connect with pipeline
source$.pipe(
  map(x => x * 2),      // pipeline directly
  filter(x => x > 5)    // pipeline directly
).subscribe(console.log);

Point 3: Avoid Nested Subscribe

typescript
// ❌ Bad example: nested subscribe
getUser$(userId).subscribe(user => {
  getOrders$(user.id).subscribe(orders => {  // 👈 more subscribe in subscribe (nested)
    console.log(user, orders);
  });  // 👈 Unsubscribing becomes more complicated
});

// ✅ Good example: flatten with mergeMap
getUser$(userId).pipe(
  mergeMap(user =>                // flatten inner Observable with mergeMap
    getOrders$(user.id).pipe(
      map(orders => ({ user, orders }))
    )
  )
).subscribe(({ user, orders }) => console.log(user, orders));  // only one subscription location

Point 4: Organize in 3-Step Separation Syntax

An important technique that greatly improves the readability and maintainability of RxJS code is the stage separation syntax.

typescript
// ❌ Bad example: a one-liner with everything mixed in
fromEvent(document, 'click').pipe(
  map(event => (event as MouseEvent).clientX),
  filter(x => x > 100),
  throttleTime(200)
).subscribe({
  next: x => console.log('Click position:', x),
  error: err => console.error(err)
});

Problems

  • Mixed stream definitions, conversions, and subscriptions
  • Difficult to debug (not sure where the problem is occurring)
  • Hard to test
  • Not reusable
typescript
// ✅ Good example: separation into 3 stages

import { filter, fromEvent, map, throttleTime } from "rxjs";

// 1. Observable definition (source of stream)
const clicks$ = fromEvent(document, 'click');

// 2. Pipeline definition (data conversion process)
const processed$ = clicks$.pipe(
  map(event => (event as MouseEvent).clientX),
  filter(x => x > 100),
  throttleTime(200)
);

// 3. Subscription processing (execution of side-effects)
const subscription = processed$.subscribe({
  next: x => console.log('Click position:', x),
  error: err => console.error(err),
  complete: () => console.log('completed')
});

Advantages

  • Easy to debug - can insert console.log and tap at each stage
  • Testable - processed$ can be tested independently
  • Reusability - use clicks$ and processed$ elsewhere
  • Improved readability - code intent is clear

Stage separation syntax is one of the most practical ways to overcome RxJS difficulties.

See Chapter 10: One-Liner Hell and Stage Separation Syntax for more information.

Experiment and Understand (Using Starter Kit)

Experiment 1: Difference Between Lazy and Eager

typescript
import { Observable } from 'rxjs';

console.log('=== Promise (Eager) ===');
const promise = new Promise((resolve) => {
  console.log('Promise executed!');
  resolve(42);
});
console.log('Promise creation complete');
promise.then(value => console.log('Promise result:', value));

console.log('\n=== Observable (Lazy) ===');
const observable$ = new Observable(subscriber => {
  console.log('Observable executed!');
  subscriber.next(42);
  subscriber.complete();
});
console.log('Observable creation complete');
observable$.subscribe(value => console.log('Observable result:', value));

// Output:
// === Promise (Eager) ===
// Promise executed!
// Promise creation complete
// Promise result: 42
//
// === Observable (Lazy) ===
// Observable creation complete
// Observable executed!
// Observable result: 42

Experiment 2: Difference Between Cold and Hot

typescript
import { interval } from 'rxjs';
import { take, share } from 'rxjs';

// Cold: independent per subscription
const cold$ = interval(1000).pipe(take(3));

console.log('Cold Observable:');
cold$.subscribe(x => console.log('Cold Subscribe 1:', x));
setTimeout(() => {
  cold$.subscribe(x => console.log('Cold Subscribe 2:', x));
}, 2000);

// Hot: shared
const hot$ = interval(1000).pipe(take(3), share());

setTimeout(() => {
  console.log('\nHot Observable:');
  hot$.subscribe(x => console.log('Hot Subscribe 1:', x));
  setTimeout(() => {
    hot$.subscribe(x => console.log('Hot Subscribe 2:', x));
  }, 2000);
}, 6000);

Run it in the learning execution environment to experience the difference.

Experiment 3: Declarative vs. Imperative

typescript
import { of } from 'rxjs';
import { map, filter } from 'rxjs';

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// Imperative
console.log('=== Imperative ===');
const result1: number[] = [];
for (const num of numbers) {
  const doubled = num * 2;
  if (doubled > 10) {
    result1.push(doubled);
  }
}
console.log(result1);

// Declarative
console.log('\n=== Declarative ===');
of(...numbers).pipe(
  map(num => num * 2),
  filter(num => num > 10)
).subscribe(num => console.log(num));

Comprehension Check

See if you can answer the following questions.

markdown
## Basic Concepts
- [ ] Name three differences between Promise and Observable
- [ ] Explain the difference between Lazy and Eager
- [ ] Explain the difference between Cold and Hot with examples

## Practice
- [ ] Explain why processing should not be completed within a subscribe
- [ ] Understand how to modify nested subscribe
- [ ] Know how to convert Cold Observable to Hot

## Debugging
- [ ] Identify the cause when an Observable does not run
- [ ] Understand what causes a subscription to run multiple times

Next Steps

Once you have a conceptual understanding, let's move on to the practical wall.

  • Lifecycle Management Wall (in preparation) - when to subscribe/unsubscribe
  • Operator Selection Confusion (in preparation) - Criteria to choose from 100+ operators

Released under the CC-BY-4.0 license.