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, and complete handlers.
  • 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 switchMap instead.
  • Wrong flattening operatormergeMap on search lets stale responses win; use switchMap.
  • Missing unsubscribe — interval and DOM listeners run forever after route change.
  • Shared cold HTTP without shareReplay — duplicate identical requests per subscriber.
  • shareReplay without refCount — cached streams never complete, leaking memory.
  • Side effects in map — use tap; map should stay pure for predictable tests.
  • Swallowing errors silentlycatchError(() => 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 defer for per-subscription side effects.
  • Chain transforms with pipe; keep map pure, side effects in tap.
  • Pick the correct higher-order mapper (switch, merge, concat, exhaust).
  • Multicast shared cold sources with shareReplay and 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 (switchMap vs mergeMap) 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