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.
Top comments (0)