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.

![Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2q4w48b9bzs43tynyhsf.png)