Fixing Angular race conditions with switchMap and shareReplay
Your search box works fine in dev. In production, users type fast, the network is uneven, and suddenly the results on screen don't match what they typed.
That's a race condition, and RxJS gives us clean tools to fix it.
The problem
Here's a typical typeahead search:
this.searchControl.valueChanges.subscribe(term => { this.api.search(term).subscribe(results => { this.results = results; }); }); Type "ang", then "angular". Both requests fly out. If the "ang" response arrives last, it overwrites the right results. Nested subscribes also leak and are hard to clean up.
Fix 1: switchMap cancels stale requests
this.results$ = this.searchControl.valueChanges.pipe( switchMap(term => this.api.search(term)) ); When a new value arrives, switchMap unsubscribes from the previous inner request and cancels it. Only the latest request can ever emit.
Fix 2: reduce noise with debounceTime and distinctUntilChanged
this.results$ = this.searchControl.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), switchMap(term => this.api.search(term)) ); -
debounceTime(300)waits until the user pauses typing. -
distinctUntilChanged()skips repeats of the same value.
This means fewer API calls and less load on your backend.
Fix 3: handle errors without killing the stream
If the HTTP call errors, the whole stream dies and search stops working. Catch the error inside the inner observable:
switchMap(term => this.api.search(term).pipe( catchError(() => of([])) ) ) Second race: duplicate requests with multiple subscribers
Say a component template reads the same observable twice:
<div *ngIf="(user$ | async) as user">{{ user.name }}</div> <div>{{ (user$ | async)?.email }}</div> Each async pipe subscribes separately, so you get two HTTP calls, which can return different data at different times.
Fix 4: shareReplay shares one request
user$ = this.http.get<User>('/api/user').pipe( shareReplay({ bufferSize: 1, refCount: true }) ); Now all subscribers share one request, and late subscribers get the last cached value.
Why refCount: true? Without it, the source subscription stays alive even after all subscribers leave, which can cause leaks. With it, the source is released when nobody is listening.
Putting it together
@Component({ /* ... */ }) export class SearchComponent { searchControl = new FormControl('', { nonNullable: true }); results$ = this.searchControl.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), switchMap(term => this.api.search(term).pipe(catchError(() => of([]))) ), shareReplay({ bufferSize: 1, refCount: true }) ); constructor(private api: SearchService) {} } <input [formControl]="searchControl" /> <ul> <li *ngFor="let r of results$ | async">{{ r.name }}</li> </ul> No manual subscriptions, no cleanup code, and no stale results.
When NOT to use switchMap
switchMap cancels in-flight work, which is perfect for reads but risky for writes. Pick the operator by intent:
| Operator | Use for |
|---|---|
switchMap | Searches, autocomplete, latest-wins reads |
concatMap | Writes that must run in order |
exhaustMap | Ignore repeat clicks, such as a submit button |
mergeMap | Independent parallel work |
Using switchMap on a save request could cancel a write mid-flight, so use concatMap or exhaustMap there.
Key takeaways
- Nested subscribes cause race conditions and leaks.
-
switchMapgives you "latest wins" for reads. -
debounceTimeanddistinctUntilChangedcut unnecessary calls. -
shareReplay({ bufferSize: 1, refCount: true })prevents duplicate requests. - Choose the flattening operator based on what the request does.
I've used these patterns on large Angular applications, and they remove a whole class of "works on my machine" bugs. What race condition have you run into? Let me know in the comments.