Quick answer: RxJS operators are the functions that transform observable streams, and in Angular they matter most in four places: HTTP calls, forms, router events and component communication. map reshapes values, filter drops them, switchMap cancels the previous inner request when a new one starts, and combineLatest merges streams into one. Pipe operators instead of subscribing manually, and unsubscribe through async pipe or DestroyRef.
Table of Contents
Why Angular is built on observables
Angular’s asynchronous surfaces all speak the same language. HttpClient returns observables, form controls expose value streams, the router publishes events, and @Output() emitters are observables too. One mental model covers every one of them: a stream produces values over time, operators transform the stream, and a subscriber receives the result. Learning the operators once pays off in every corner of the framework.
The alternative — callbacks and promises — is strictly less expressive. A promise resolves once; an observable can emit a hundred times and be cancelled mid-flight. That cancellation is why observables are non-negotiable for typeahead search and route-driven fetching: when the user types another character or navigates away, the previous request must stop, and only a cancellable stream can do that. The HTTP layer where this plays out is covered in the HTTP client guide.
Pipe: the operator habit
Operators go inside pipe(), never as chained methods. The pipe form keeps tree-shaking effective, keeps the operator list readable at the top of the pipeline, and makes the order of transformations explicit — which matters, because map before filter and after it are different programs.
import { map, filter, tap } from 'rxjs/operators';
this.http.get<Product[]>('/api/products').pipe(
tap(products => console.log('raw', products.length)), // side effect
filter(product => product.price < 100),
map(products => products.map(p => p.name)),
).subscribe(names => this.names.set(names));
Order is design, not style. Filtering before mapping avoids transforming values that will be dropped; tapping after the final map observes exactly what subscribers receive. Reading a pipeline top to bottom should tell the same story a well-named function would, and if it does not, the pipeline wants splitting into named functions composed with pipe(fn1, fn2).
map, filter and tap in HTTP workflows
The three workhorse operators cover most HTTP code. map reshapes the payload, typically unwrapping a server envelope or picking a field. filter drops emissions that should not reach the subscriber, including nulls from a search-as-you-type stream. tap runs side effects — logging, cache bookkeeping, progress markers — without changing the values, which is what keeps it separate from map.
// unwrap an API envelope and skip empty search terms
this.searchControl.valueChanges.pipe(
map(term => (term ?? '').trim()),
filter(term => term.length >= 3),
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.http.get<Result[]>('/api/search', {
params: { q: term },
}).pipe(
map(response => response.items),
)),
).subscribe(items => this.results.set(items));
The valueChanges pipeline is the canonical typeahead, and every operator has a job: length filtering stops single-character requests, debounce batches fast typing, distinctUntilChanged skips re-searching the same term, and switchMap makes the actual request cancellable. Remove any one and the pipeline either hammers the server or shows stale results.
switchMap, mergeMap, concatMap and exhaustMap
The four mapping operators differ in exactly one way: what happens when the outer stream emits while an inner observable is still running. That single question decides which one belongs in each situation, and choosing wrong is the most common RxJS bug in Angular codebases.
| Operator | On new outer emission | Use when |
|---|---|---|
switchMap | Cancels the previous inner | Search, route params, anything where only the latest matters |
mergeMap | Runs both concurrently | Independent requests, websockets, unbounded parallel work |
concatMap | Queues the next | Order matters: saves, uploads, sequential writes |
exhaustMap | Ignores the new emission | Submit buttons: one in-flight submission wins |
A save button illustrates the choice well. switchMap cancels an earlier save when the user clicks again, which may be wrong for a form with unsaved fields; exhaustMap ignores the second click until the first completes, which matches the mental model of a submit button. The same reasoning puts concatMap on an upload queue, where order matters and nothing is discarded, and mergeMap on a fan-out of independent calls that should all finish.
Combining streams: combineLatest, withLatestFrom, zip
Combining operators merge multiple streams into one. combineLatest emits whenever any source emits after all have emitted at least once, which makes it the reactive way to compute a value from several controls — a total that depends on quantity and price re-emits when either changes. withLatestFrom takes the latest value from a secondary stream only when the primary emits, which fits a save that needs the current filters but should not re-save when filters change. zip pairs values by index, and forkJoin waits for all sources to complete, taking the place of Promise.all.
// total re-emits when quantity or price changes
const total = combineLatest([
this.quantity.valueChanges,
this.price.valueChanges,
]).pipe(
map(([qty, price]) => qty * price),
);
// initial values so combineLatest emits immediately
const totalWithInitial = combineLatest({
qty: this.quantity.valueChanges.pipe(startWith(1)),
price: this.price.valueChanges.pipe(startWith(0)),
}).pipe(
map(({ qty, price }) => qty * price),
);
The startWith idiom solves the classic combineLatest surprise: no emission until every source has emitted once, so a form that has not been touched computes nothing. Seeding each source with its initial value makes the combined stream emit immediately, which is almost always what a template binding expects.
Errors, retry and cleanup
An unhandled error kills the stream: after one failed request, a subscription stops receiving anything, which is why a typeahead that errored once appears frozen until reload. The fixes are catchError, which recovers with a fallback value or a new stream, and retry, which resubscribes — with a count and, in modern RxJS, a delay and a reset-on-success configuration.
this.http.get<Config>('/api/config').pipe(
retry({ count: 3, delay: 1000 }),
catchError(() => of(DEFAULT_CONFIG)), // stream survives with fallback
);
Cleanup follows the same discipline as effect cleanup in React: something subscribed must be unsubscribed. The async pipe handles it in templates, unsubscribing when the component is destroyed. In class code, takeUntilDestroyed() from @angular/core/rxjs-interop ties the stream’s lifetime to the component’s, replacing the boilerplate Subject-and-ngOnDestroy pattern with a single call. Either way, leaking subscriptions across navigation is the bug to grep for in review.
RxJS and signals: which one when
Angular signals now cover the value-and-notify role that subjects often played in older code. A signal is pull-based and synchronous: read it in a template, it tracks; update it, dependents re-run. An observable is push-based and asynchronous: values arrive over time and operators compose the flow. The two are complementary rather than competing, and the dividing line is the source of values.
| Scenario | Prefer | Why |
|---|---|---|
| Component state, derived values | Signals | Synchronous, fine-grained, no unsubscribe |
| HTTP requests, router events, websockets | Observables | Async sources with cancellation built in |
| Typeahead and debounce | Observables | Operator chains handle timing natively |
| State read across many components | Signals | Simple to snapshot, debug and test |
The conversion between them is one line each way: toObservable() turns a signal into a stream, toSignal() turns a stream into a signal. That bridge means a decision made today is not a one-way door — start with observables where the source demands it, expose signals where components consume state, and let the interop functions carry values across. The broader state-management picture this fits into is covered in the signals and state guide. To learn Angular properly with a mentor, Ampersand Academy teaches web development one-to-one.
Subject types: Subject, BehaviorSubject, ReplaySubject and AsyncSubject
A Subject is both an observable and a subscriber, which makes it the connective tissue of Angular state code: services expose it as a stream, components subscribe, and event emitters publish into it. The variants differ in what a late subscriber receives, and picking the wrong one is the source of most “why is my value undefined on first render” bugs.
| Type | New subscriber receives | Typical use |
|---|---|---|
Subject | Nothing | Event streams: clicks, navigation, saves |
BehaviorSubject | The latest value | Current state: theme, user, filters |
ReplaySubject(n) | The last n values | Replay to late subscribers, audit trails |
AsyncSubject | The final value on completion | Single async results that must emit once |
The migration path from BehaviorSubject to signals is worth naming explicitly, because most Angular codebases are somewhere on it. A BehaviorSubject holding current state usually becomes a signal: reads get simpler, subscriptions disappear, and the value is synchronously available in tests. Keep the observable surface only where something upstream genuinely is a stream, and convert at the boundary with toObservable rather than maintaining two parallel states.
Custom operators and reusable pipelines
When the same pipeline appears in three components, it wants a name. A custom operator is a function taking an observable and returning one, registered through pipe` and imported like any built-in. The debounced typeahead, the retry-with-backoff, the trace-and-recover — each is a few lines once and an unreadable inline chain no more.
export function retryWithBackoff(max = 3, base = 400) {
return function <T>(source: Observable<T>) {
return source.pipe(
retry({
count: max,
delay: (error, retryCount) =>
timer(base * 2 ** retryCount),
}),
);
};
}
// usage reads as intent, not implementation
this.http.get<Config>('/api/config').pipe(
retryWithBackoff(3, 400),
catchError(() => of(DEFAULT_CONFIG)),
);
Two conventions keep custom operators maintainable. Generic signatures, so an operator written for one payload works for every type it encounters, and marble-test coverage, because timing bugs in an operator surface months later in a context that has no connection to its author. RxJS’s own test scheduler makes those tests a few lines each: advance the virtual clock, assert the emitted sequence, and the operator’s timing contract is pinned for as long as the suite runs.
Debugging streams that behave differently in production
Observable bugs share three shapes, and naming them makes each one findable. The silent stream never emits because a filter excludes everything or a source was never subscribed. The hot stream emits before the subscriber attaches because it was multicasted from a live source. The growing stream leaks subscriptions across navigation, showing up as memory climbing and duplicate requests after the tenth route change.
// make emissions visible while debugging
import { tap } from 'rxjs/operators';
this.data$.pipe(
tap({
next: v => console.debug('[data$] emit', v),
error: e => console.debug('[data$] error', e),
complete: () => console.debug('[data$] complete'),
}),
);
The tooling answer for production is a subscription tracker: Angular’s own DestroyedRef hooks or a library that records active subscriptions per component and dumps them when the count crosses a threshold. Paired with the leak discipline from the cleanup section — every subscription tied to a lifecycle, never a bare subscribe in a service — the class of bug shrinks to the ones your tests have not reached yet, which is the honest definition of a well-run stream layer.
The discipline that prevents all three shapes is treating every subscription as a resource with an owner. A stream created inside a component belongs to that component, and the lifecycle binding makes the compiler enforce it. A stream created in a service belongs to the application and must be deliberate about it, because nothing will clean it up for you. Most leaking code is neither of those things honestly declared, and the fix is almost always moving the subscribe call to where the lifetime is already known.
For teams adopting the interop functions, one boundary is worth setting early. Convert to a signal at the edge where a component consumes state, and keep the observable as the internal representation inside the service, or the reverse, but pick one direction and keep it consistent across the codebase. Mixed boundaries, where one service exposes signals and the next exposes observables for the same kind of value, force every consuming component to learn both idioms and double the surface where a subscription can leak. Consistency here is worth more than any individual choice.
Whichever direction you choose, keep the conversion point visible. A signal read inside a component template is obviously a signal, but a conversion buried three calls deep inside a service hides the cost model from the reader and makes it easy to create a new observable on every change detection cycle by accident. Converting at the boundary and naming the result clearly keeps the performance characteristics of the stream predictable and reviewable.
New to Angular? Build the fundamentals first in the free 14-part Angular course, then come back to this guide for the deeper material.
Testing RxJS operators with marble diagrams
Marble diagrams turn timing into something a test can assert. Jasmine ships expectObservable against a scheduler, and for RxJS operators that means the whole stream — emissions, timing, completion — is compared against a string like --a--b--c| instead of a hand-rolled flag. A custom operator that debounces, retries or switches deserves exactly this: a test that fails when the timing changes, not only when the values do.
it('debounces and switches', () => {
const source = cold('--a-b--c----|');
const expected = '--a-----b---|';
const result = source.pipe(
debounceTime(10, scheduler),
switchMap(v => of(v)),
);
scheduler.run(({ expectObservable }) => {
expectObservable(result).toBe(expected);
});
});
Write the tests around the RxJS operators you wrote, not the ones you imported. The moment a custom operator carries project rules — a retry budget, a stream of search terms, a queue that must not reorder — the test becomes the specification: marble frames document how long the operator waits, when it cancels and what it does on error, in a way prose never does.
Testing library streams keeps the same discipline. Export the stream as a cold observable inside the service and the test controls the scheduler, so clock-dependent RxJS operators like debounceTime and interval never make the suite flaky. If a test needs a real timeout to pass, the production stream is already hiding an assumption that will surface at two in the morning instead.
One gap marble tests do not cover is the DOM. Pair them with a component test that drives the input and asserts the rendered list, and the coverage story is complete: the RxJS operators behave on paper, and the component that subscribes behaves on screen. Two layers, two kinds of failure, neither hidden by the other.
Keep the marble vocabulary consistent across the RxJS operators tests: one dash is one frame, the same scheduler everywhere, the same completion marker. Shared conventions make a failing test readable at a glance, and reviewers stop re-deriving what --a--| was supposed to mean.
Finally, run the marble tests under the same CI gate as everything else. RxJS operators are pure enough to test in milliseconds, so there is no excuse for a skip: a suite that proves the stream cancels stale requests is the cheapest regression insurance in the application.
Determinism is the whole point. The moment a test depends on wall-clock time, the RxJS operators under test are no longer the only variable: the CI machine becomes one. Injecting a scheduler everywhere removes the machine, and a suite that runs in milliseconds stays a suite people are willing to run on every commit.
Build a tiny helper for it. A testScheduler() factory that returns a fresh VirtualTimeScheduler per spec keeps marble tests isolated from each other, gives the RxJS operators a predictable frame budget, and makes the timing assertions in one file readable against the timing assertions in the next.
If you only adopt one habit from this section, make it the scheduler. Once timing is asserted rather than assumed, tests of RxJS operators run fast enough to gate every pull request, and a refactor that touches the stream stops being the change everyone fears. A suite of deterministic RxJS operators tests is cheap enough to run everywhere, which is the only reason it stays honest.
Frequently asked questions
What is the difference between map and switchMap?
map transforms a value synchronously inside the stream. switchMap maps each value to a new observable and cancels the previous inner observable when the outer emits again, which is what makes search and route-driven fetching cancel stale requests.
Why does my combineLatest not emit?
combineLatest waits until every source has emitted at least once. Seed each source with its current value using startWith, or check for a control that has never been touched, and the combined stream will emit immediately.
Do I need to unsubscribe from HttpClient observables?
HTTP observables complete after one response, so a plain subscribe does not leak. Pipelines from valueChanges or router events never complete on their own — use the async pipe or takeUntilDestroyed for those.
switchMap or concatMap for a save button?
concatMap if each click is a distinct save that must complete in order; exhaustMap if repeated clicks during an in-flight save should be ignored. switchMap is usually wrong here, because it discards the earlier save.
Should I replace RxJS with signals?
No. Signals are for synchronous component state and derived values; observables remain the right tool for asynchronous event sources with cancellation. Convert between them with toSignal and toObservable and use each where its model fits.

