RxJS Interview Questions
RxJS is the reactive backbone of Angular: HttpClient, the Router, forms, and the async pipe all speak Observables. Interviewers probe RxJS because subtle misuse — forgetting to unsubscribe, choosing the wrong flattening operator, or treating Observables like Promises — causes the most common bugs in production Angular apps. The questions below cover the comparisons and gotchas that come up most often, with modern Angular 17–19 idioms throughout.
How do Observables differ from Promises?
This is the canonical opener. The key distinctions are lazy versus eager, single versus multiple values, and cancellable versus not.
A Promise runs its executor the moment it is created and resolves exactly once. An Observable is lazy — nothing happens until you subscribe() — and it can emit zero, one, or many values over time. Crucially, an Observable subscription is cancellable via unsubscribe(), which is how Angular aborts in-flight HTTP requests when a component is destroyed.
| Aspect | Promise | Observable |
|---|---|---|
| Execution | Eager (runs immediately) | Lazy (runs on subscribe) |
| Values | Single | 0…n over time |
| Cancellable | No | Yes (unsubscribe) |
| Operators | .then/.catch only | 100+ pipeable operators |
| Re-execution | No | Yes (each subscribe re-runs) |
import { Observable } from 'rxjs';
const obs$ = new Observable<number>((subscriber) => {
console.log('producer runs');
subscriber.next(1);
subscriber.next(2);
subscriber.complete();
});
console.log('before subscribe'); // nothing has run yet
obs$.subscribe((v) => console.log('got', v));
Output:
before subscribe
producer runs
got 1
got 2
The producer logging after “before subscribe” demonstrates laziness — a frequent follow-up question. A Promise would have logged first.
What are the flattening operators and when do you use each?
When an outer Observable emits values that themselves trigger inner Observables (e.g., a search box that fires an HTTP request per keystroke), you need a flattening operator to manage the inner subscriptions. The choice determines concurrency and cancellation behaviour — a guaranteed interview question.
| Operator | Strategy | Use case |
|---|---|---|
switchMap | Cancels the previous inner Observable | Type-ahead search, latest-wins reads |
mergeMap | Runs all inner Observables concurrently | Independent parallel writes |
concatMap | Queues inner Observables in order | Ordered, sequential writes |
exhaustMap | Ignores new values while one is active | Login buttons, debounce-by-ignore |
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<input [formControl]="query" placeholder="Search users" />
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient);
query = new FormControl('', { nonNullable: true });
results = toSignal(
this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term) =>
this.http.get<{ id: number; name: string }[]>(`/api/users?q=${term}`),
),
),
{ initialValue: [] as { id: number; name: string }[] },
);
}
Here switchMap cancels the previous HTTP request when a new keystroke arrives, so a slow earlier response can never overwrite a newer one. Using mergeMap instead would cause out-of-order results.
What are Subjects, and how do the variants differ?
A Subject is both an Observable and an Observer — you can subscribe to it and push values into it with next(). This makes Subjects the standard tool for multicasting and for event-bus-style communication between unrelated components.
Subject— has no initial value; late subscribers miss prior emissions.BehaviorSubject— requires a seed value and replays the latest value to new subscribers. Ideal for state.ReplaySubject— replays the last n emissions to new subscribers.AsyncSubject— emits only the final value, and only on completion.
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class CartService {
private itemCount = new BehaviorSubject<number>(0);
readonly itemCount$ = this.itemCount.asObservable();
add(): void {
this.itemCount.next(this.itemCount.value + 1);
}
}
Expose
asObservable()rather than the raw Subject so consumers can’t callnext()and corrupt your state — a detail interviewers like to hear.
How do you prevent memory leaks from subscriptions?
A leaked subscription keeps a component alive in memory and continues running side effects after the view is gone. There are several idiomatic fixes; mentioning more than one signals depth.
- Prefer the
asyncpipe — Angular subscribes and unsubscribes for you automatically. - Use
toSignal()— the modern interop helper ties the subscription to the injection context and cleans up on destroy. takeUntilDestroyed()— the cleanest manual approach in Angular 16+.
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ selector: 'app-ticker', standalone: true, template: '' })
export class TickerComponent {
constructor() {
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe((n) => console.log('tick', n));
}
}
When called in a constructor, takeUntilDestroyed() infers the DestroyRef automatically; outside an injection context you pass one in explicitly. This replaces the older takeUntil(this.destroy$) boilerplate.
Best practices
- Reach for the
asyncpipe ortoSignal()first — manual.subscribe()should be the exception, not the default. - Choose flattening operators deliberately:
switchMapfor reads,concatMap/exhaustMapfor writes you must not duplicate or reorder. - Seed shared state with
BehaviorSubjectand expose it viaasObservable()to keep writes encapsulated. - Always pair manual subscriptions with
takeUntilDestroyed()so cleanup is impossible to forget. - Debounce and
distinctUntilChanged()user-driven streams to avoid redundant work. - Keep side effects in
tap()and data transforms inmap()so pipelines stay readable and testable.