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.
  • switchMap gives you "latest wins" for reads.
  • debounceTime and distinctUntilChanged cut 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.