Module Overview
Estimated Time: 4-5 hours | Difficulty: Advanced | Prerequisites: Module 8
- Observable creation and subscription
- Transformation, filtering, and combination operators
- Subjects and multicasting
- Error handling and recovery
- Testing observables
- Performance optimization
Observable Fundamentals
Creating Observables
Transformation Operators
Flattening Operators Comparison
Filtering Operators
Debounce vs Throttle
Combination Operators
Subjects
Subjects are both Observable and Observer — they can emit values and be subscribed to. Think of a regular Observable like a recorded podcast (each listener hears the same thing from the beginning), while a Subject is like a live radio broadcast (listeners only hear what is broadcast after they tune in). Understanding which Subject to use is critical for state management:State Management with BehaviorSubject
Error Handling
Multicasting
Share a single subscription among multiple subscribers:Custom Operators
Real-World Patterns
Polling with Pause
Typeahead Search
Race Condition Prevention
Practice Exercise
Exercise: Build a Live Dashboard
Create a dashboard that:
- Polls multiple APIs every 30 seconds
- Combines data from all sources
- Pauses polling when tab is hidden
- Shows connection status
- Has retry with exponential backoff
Solution
Solution
Summary
Interview Deep-Dive
Q: Explain the difference between combineLatest and forkJoin. You have a dashboard that needs data from three APIs -- when would you use each?
Q: Explain the difference between combineLatest and forkJoin. You have a dashboard that needs data from three APIs -- when would you use each?
Strong Answer: combineLatest emits whenever ANY source observable emits, combining the latest value from each source. forkJoin waits until ALL source observables complete, then emits a single value with the last emission from each.For a dashboard loading three APIs (users, orders, revenue), I would use forkJoin if I want to show everything at once — all three requests fire in parallel, and the component renders when all three complete. This gives a clean loading-to-loaded transition. If one fails, forkJoin errors and I can show a single error state.I would use combineLatest if the data sources are long-lived or emit multiple values. For example, if users comes from an HTTP call but orders comes from a WebSocket stream that updates in real-time, combineLatest re-emits whenever orders pushes new data, combining it with the last fetched users. forkJoin would never emit because the WebSocket never completes.The common mistake: using combineLatest for three HTTP calls. It works, but it has an edge case — combineLatest does not emit until ALL sources have emitted at least once. If one request is significantly slower, the other two responses sit in memory waiting. With HTTP calls that each emit once and complete, forkJoin is semantically more correct and slightly more efficient.Follow-up: What about race conditions — if the orders API responds before the users API, does combineLatest handle that?
Answer: combineLatest does not emit until every source has emitted at least once. So even if orders responds first, no emission happens until users also responds. After that initial emission, any new emission from any source triggers a new combined emission with the latest from all. There is no race condition in the traditional sense, but there is a subtlety: if users re-emits (say from a cache layer), you get a new combined emission even though only users changed. You might want distinctUntilChanged on the combined output if downstream logic is expensive.
Q: You have a memory leak in production -- the app gets slower over time and eventually crashes. You suspect RxJS subscriptions are not being cleaned up. How do you find and fix the leaks?
Q: You have a memory leak in production -- the app gets slower over time and eventually crashes. You suspect RxJS subscriptions are not being cleaned up. How do you find and fix the leaks?
Strong Answer: First, I would reproduce the issue using Chrome DevTools Memory tab. Take a heap snapshot, navigate away from a suspected leaky page and back several times, take another snapshot, and compare. If the detached DOM nodes or Angular component instances keep growing, you have a leak. The “Comparison” view shows exactly which objects are accumulating.For finding the specific leaky subscriptions, I look for four patterns. First, subscribe() calls in components without corresponding unsubscribe in ngOnDestroy. Second, event listeners added with fromEvent without takeUntil or takeUntilDestroyed. Third, setInterval or setTimeout without clearInterval/clearTimeout in ngOnDestroy. Fourth, closures in subscribe callbacks that reference the component (this), preventing garbage collection.For fixing, I apply a hierarchy of approaches. Best: use the async pipe or toSignal(), which automatically unsubscribe. Next best: use takeUntilDestroyed(this.destroyRef) on every subscription. Acceptable: use a manual destroy$ Subject with takeUntil. Worst (but sometimes necessary): manually track and unsubscribe Subscription objects.For prevention, I add an ESLint rule (rxjs-no-unsafe-subscribe or similar) that flags bare subscribe() calls without takeUntil. In code review, any subscribe() call without a cleanup mechanism is an automatic request for changes.Follow-up: How does takeUntilDestroyed differ from the manual destroy$ pattern?
Answer: takeUntilDestroyed is cleaner because it automatically creates and manages the destruction notifier using Angular’s DestroyRef. You do not need to create a Subject, remember to call next() and complete() in ngOnDestroy, or worry about forgetting the complete() call (which is a subtle leak itself). The caveat: takeUntilDestroyed without arguments only works inside an injection context (constructor or field initializer). If you use it in ngOnInit, you must pass the DestroyRef explicitly.
Q: Design a typeahead search component that handles all the edge cases -- debouncing, minimum query length, cancellation of stale requests, error recovery, and empty state. Walk through the RxJS pipeline.
Q: Design a typeahead search component that handles all the edge cases -- debouncing, minimum query length, cancellation of stale requests, error recovery, and empty state. Walk through the RxJS pipeline.
Strong Answer: The pipeline chains six operators, each solving a specific problem. Starting from the input’s valueChanges observable: debounceTime(300) prevents firing on every keystroke — it waits for 300ms of silence. distinctUntilChanged() prevents duplicate requests if the user types, deletes, and retypes the same query. filter(term => term.length >= 2) skips queries that are too short to produce useful results.switchMap is the critical operator — when a new search term arrives, it cancels any in-flight HTTP request from the previous term. This prevents race conditions where “ang” returns after “angular” and overwrites the correct results. Inside the switchMap, I wrap the HTTP call with catchError that returns an empty array, so a failed search does not break the entire stream.For the empty state: I use startWith(”) at the beginning of the pipeline so the component renders immediately with an initial state. When the search term is empty (cleared by the user), I use a short-circuit in the switchMap: if the term is empty, return of([]) instead of calling the API.The complete pipeline: searchControl.valueChanges.pipe(startWith(”), debounceTime(300), distinctUntilChanged(), switchMap(term => term.length < 2 ? of([]) : searchService.search(term).pipe(catchError(() => of([])))), takeUntilDestroyed()). The result is a resilient, performant search that gracefully handles every user interaction pattern.Follow-up: How would you add a loading indicator without introducing another subscription?
Answer: I use tap before and after switchMap to set a loading signal. tap(() => this.loading.set(true)) before the switchMap, and tap(() => this.loading.set(false)) after. But there is a subtlety: the “after” tap needs to be inside the switchMap’s inner observable, not outside, because the outer observable only emits once switchMap’s inner observable emits. I typically use finalize(() => this.loading.set(false)) inside the switchMap’s inner observable to handle both success and error cases.
Next Steps
Next: Change Detection & Performance
Optimize Angular performance with change detection strategies