Guide
RxJS fundamentals explained
A user types “har” in a search box. Three keystrokes fire three API
calls; the slowest response wins and shows stale results for
“harbor.” A WebSocket pushes GPS pings every second while the tab is
hidden, burning battery. A route change starts a fetch that completes after the
user already navigated away, briefly flashing the wrong page. Promises model
one async result; real UIs are rivers of events over time.
RxJS (Reactive Extensions for JavaScript) gives you
Observables — lazy, cancellable streams — and a
library of operators to map, filter, merge, debounce, and
recover without nested callback pyramids. Angular ships RxJS by default;
React teams reach for it when event composition outgrows useEffect.
This guide covers the Observable contract, creation and pipe operators,
higher-order mapping (switchMap, mergeMap,
concatMap, exhaustMap), Subjects and multicasting,
error and teardown patterns, a Harbor Fleet live tracker worked example, a
reactive-tooling decision table, common pitfalls, and a production checklist
— with links to our
Angular fundamentals guide,
frontend state management guide,
and
TanStack Query fundamentals
for where server-state libraries fit alongside streams.
What RxJS is and when streams beat promises
An Observable is a function that, when subscribed to, pushes
zero or more values over time, then optionally completes or errors. Unlike a
Promise, which eagerly starts and always resolves once, an Observable is
lazy: nothing runs until something calls
subscribe(). Unsubscribing cancels in-flight work — critical
for search-as-you-type and route-guarded HTTP.
RxJS shines when you have multiple event sources that must be coordinated: DOM input, timers, WebSockets, router events, drag gestures, animation frames. If your problem is “fetch this JSON once on mount,” a Promise or TanStack Query is simpler. If it is “react to every change, cancel stale work, merge live ticks with user filters,” streams earn their complexity.
Core vocabulary
- Observable — the stream producer; cold by default (each subscriber gets its own execution).
- Observer — the consumer with
next,error, andcompletehandlers. - Subscription — the link; call
unsubscribe()to tear down. - Operator — pure function that returns a new Observable (e.g.
map,filter). - Subject — both Observable and Observer; multicast hub for hot streams.
Creating and subscribing to Observables
Most apps rarely hand-roll Observables; they use creation operators:
of(1, 2, 3)— emit a fixed sequence synchronously, then complete.from(promiseOrArray)— lift a Promise, iterable, or async iterable into a stream.fromEvent(button, 'click')— DOM and Node event targets.interval(1000)/timer(0, 1000)— periodic emissions for polling UIs.defer(() => fetch(...))— factory runs per subscription; avoids shared mutable fetch state.
Subscribe with an object or three callbacks:
const sub = source$.subscribe({
next: (value) => console.log(value),
error: (err) => console.error(err),
complete: () => console.log('done'),
});
// later: sub.unsubscribe();
Always store subscriptions you need to cancel — component destroy,
route leave, modal close. In Angular, takeUntilDestroyed() or an
ngOnDestroy pattern prevents leaks. In React, tie teardown to
useEffect cleanup return functions.
Pipe, map, and filtering operators
pipe() chains operators left-to-right; each returns a new
Observable without mutating the source. Start with transforms every team uses daily:
map(x => x * 2)— synchronous transform per emission.filter(x => x > 0)— drop values that fail a predicate.tap({ next: log })— side effects without changing the stream (debugging, metrics).take(n)/takeUntil(notifier$)— auto-complete after N values or an event.debounceTime(300)— wait for a pause before emitting (search boxes).distinctUntilChanged()— skip consecutive duplicates (avoid redundant renders).auditTime(1000)/throttleTime(1000)— sample at most once per window (scroll, resize).
Composition example for search:
fromEvent(input, 'input').pipe(
map((e) => e.target.value.trim()),
debounceTime(300),
distinctUntilChanged(),
filter((q) => q.length >= 2),
switchMap((q) => searchApi(q)),
)
The order matters: debounce before switchMap so you do not spawn
HTTP calls for every intermediate keystroke.
Higher-order mapping: switchMap, mergeMap, concatMap, exhaustMap
When each emission triggers another async source (HTTP, DB query), you need a higher-order mapping operator. Picking the wrong one is the most common RxJS production bug.
| Operator | Behavior when a new inner Observable arrives | Typical use |
|---|---|---|
switchMap |
Unsubscribes the previous inner stream | Search, autocomplete, route-param fetches (only latest matters) |
mergeMap (flatMap) |
Keeps all inner streams running in parallel | Fire-and-forget analytics, parallel uploads |
concatMap |
Queues; processes one inner stream at a time in order | Sequential writes where order must be preserved |
exhaustMap |
Ignores new emissions while an inner stream is active | Submit buttons, prevent double-click purchases |
Rule of thumb: default to switchMap for read/search UIs. Reach for
mergeMap only when every request must complete. Use
exhaustMap on destructive actions. If results must land in emission
order despite variable latency, concatMap is the safe choice —
at the cost of throughput.
Combining multiple streams
combineLatest([a$, b$])— emit whenever any source emits, using latest values from each (form validation).forkJoin([a$, b$])— wait for all to complete, emit final array (parallel bootstrapping).merge(a$, b$)— interleave emissions from shared-type streams.withLatestFrom(trigger$, data$)— on trigger, pair with latest data without re-firing data.
Subjects, multicasting, and hot vs cold
A cold Observable (default HTTP wrapper) runs its producer per subscriber — two subscribers means two network calls. A hot Observable shares one execution: late subscribers miss earlier values unless buffered.
Subject variants
Subject— multicast with no memory; subscribers see only future values.BehaviorSubject<T>(initial)— replays the latest value to new subscribers (current user, theme).ReplaySubject(n)— buffer the last n emissions.AsyncSubject— emits only the final value on complete (rare in UI code).
Turn cold HTTP into a shared hot stream with:
const users$ = http.get('/api/users').pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);
shareReplay caches the last emission so ten components do not hammer
the same endpoint. Use refCount: true so the source tears down when
all subscribers leave — otherwise cached streams leak memory in SPAs.
Error handling, retry, and teardown
Uncaught errors in an Observable chain terminate the stream. Production code needs deliberate recovery:
catchError((err) => of(fallback))— replace the failed stream with a recovery Observable.retry({ count: 3, delay: 1000 })— transient network blips; never blind-retry POST payments.finalize(() => hideSpinner())— runs on complete, error, or unsubscribe.throwError(() => new Error('msg'))— factory form avoids stack capture bugs in RxJS 7+.
Pair UI feedback with tap and finalize rather than
sprinkling try/catch inside subscribe. Prefer
async pipe in Angular templates — it manages subscribe and
unsubscribe automatically. In imperative code, use take(1) for
one-shot reads or firstValueFrom(obs$) when you must bridge to
async/await at a boundary.
Schedulers and testing
RxJS schedulers control when work runs (microtask, animation
frame, async). Most app code uses defaults; tests use
TestScheduler with marble diagrams (-a-b-|) to
simulate time without setTimeout flakes. Marble tests are worth the
learning curve for teams shipping complex stream logic.
Worked example: Harbor Fleet live shipment tracker
Harbor Fleet is a fictional logistics dashboard: a map shows vessel positions updated via WebSocket, a sidebar filters by status, and a search box finds containers by ID. Streams replace tangled listeners.
WebSocket tick stream
socket$ wraps webSocket('/ws/fleet') with
retry({ delay: 5000 }) and shareReplay(1) so map and
table components share one connection. Parse messages in map;
filter malformed payloads with filter(Boolean).
User filter and search
Status toggles feed a BehaviorSubject<FleetFilter>. Search
input pipes through debounceTime(250) and
distinctUntilChanged(), then switchMap to
GET /api/containers?q= — cancelling stale queries when the
user types faster than the network.
Combining for the map layer
combineLatest([socket$, filter$]) produces marker updates: live
positions filtered client-side without reconnecting. withLatestFrom
pairs map click events with the latest selection stream to open detail drawers
without redundant API calls.
Visibility and battery
fromEvent(document, 'visibilitychange') merged with
startWith(document.visibilityState) gates
interval(1000) polling: pause ticks when the tab is hidden via
switchMap to EMPTY. Teardown on dashboard unmount
unsubscribes the root Subscription and closes the socket.
The lesson: one declarative pipeline per concern beats a central event bus with stringly typed callbacks.
Reactive tooling decision table
| Tool | Best for | Weak fit |
|---|---|---|
| Promise / async-await | Single-shot fetch, one-click mutations | Multi-event coordination, cancellation |
| RxJS Observables | Streams, composition, cancelable async | Simple CRUD with cache invalidation |
| TanStack Query | Server state, cache, background refetch | High-frequency DOM events, WebSockets |
| Signals (Angular / Solid) | Fine-grained UI reactivity | Complex event algebra without interop |
| EventEmitter / mitt | Fire-and-forget pub/sub | Backpressure, error propagation, operators |
Mature stacks mix tools: Query for REST entity caches, RxJS for WebSocket overlays and form streams, Signals for local component state. Do not rewrite working Query hooks as Observables without a coordination problem to solve.
Common pitfalls
- Nested subscribe — callback hell with memory leaks; flatten with
switchMapinstead. - Wrong flattening operator —
mergeMapon search lets stale responses win; useswitchMap. - Missing unsubscribe — interval and DOM listeners run forever after route change.
- Shared cold HTTP without
shareReplay— duplicate identical requests per subscriber. shareReplaywithoutrefCount— cached streams never complete, leaking memory.- Side effects in
map— usetap;mapshould stay pure for predictable tests. - Swallowing errors silently —
catchError(() => EMPTY)without logging hides production failures. - Over-RxJS-ing — wrapping static config in Observables adds complexity with no cancelation benefit.
Production checklist
- Identify event sources and whether cancellation or ordering matters.
- Choose creation operators; prefer
deferfor per-subscription side effects. - Chain transforms with
pipe; keepmappure, side effects intap. - Pick the correct higher-order mapper (
switch,merge,concat,exhaust). - Multicast shared cold sources with
shareReplayand appropriate buffer. - Wire
catchError,retry, and user-visible error states. - Store subscriptions; unsubscribe on destroy or use framework helpers.
- Add marble or integration tests for non-trivial stream graphs.
- Document which streams are hot vs cold for the next maintainer.
- Re-evaluate quarterly: could TanStack Query or Signals replace this pipeline?
Key takeaways
- Observables model lazy, cancellable sequences — the right abstraction for events over time.
- Operators compose transforms declaratively; operator choice (
switchMapvsmergeMap) determines correctness. - Subjects and shareReplay control multicasting and cache shared work across subscribers.
- Teardown and error operators are not optional in long-lived SPAs.
- RxJS complements server-state libraries and framework reactivity — it does not replace all async patterns.
Related reading
- Angular fundamentals explained — HttpClient, Router events, and signals interop with RxJS
- Frontend state management explained — where streams fit beside global and server state
- TanStack Query fundamentals explained — cache-first server state without stream algebra
- React fundamentals explained — component effects and when to reach for external stream libraries