Race Conditions in Angular: switchMap vs mergeMap vs concatMap vs exhaustMap [2026]
A user types "ang" into a search box, then "angular". Two requests go out. The response for "ang" is slower and arrives last, so the results list shows matches for "ang" while the input says "angular". Nothing threw, every test passed, and the bug only shows up on a slow network. That's a race condition: the correctness of the screen depends on which async operation finishes first, and you didn't decide the order.
This is lesson 10.6 of the Angular Tutorial. It follows lesson 10.5 on leaks, which often go hand in hand with races: a subscription that should have been cancelled is still around to deliver a stale result. You'll learn to pick the right RxJS flattening operator for each scenario, how resource() and httpResource() cancel stale requests for you, how to cancel fetch with AbortController, and how untracked() breaks effect cycles.
Three questions for any overlapping async work #
Every race comes from the same situation: a new trigger arrives while the previous operation is still running. You have to decide what happens:
| Question | Options |
|---|---|
| Is the old result still useful? | No → cancel it. Yes → let it finish |
| Must operations run in order? | Yes → queue them. No → run them in parallel |
| Should a new trigger be accepted while busy? | No → ignore it until the current one finishes |
In RxJS, those three answers map onto four operators. In signal-based code, resource() and httpResource() make the most common choice (cancel the old one) automatically.
The flattening operators #
switchMap, mergeMap, concatMap and exhaustMap all take each value from a source (keystrokes, clicks, files) and start an inner Observable (usually an HTTP request). They differ only in what they do when a new value arrives while an inner Observable is still running:
| Operator | When a new value arrives mid-flight | Order of results | Use it for |
|---|---|---|---|
switchMap |
Cancels the current inner request and starts the new one | Only the latest | Typeahead, filters, route-param loads, any "show the latest" read |
mergeMap |
Starts the new one in parallel | Whatever finishes first | Independent work: loading details for each row, fire-and-forget logging |
concatMap |
Queues the new one until the current finishes | Same as input | Ordered writes: upload queue, autosave of successive edits, event logs |
exhaustMap |
Ignores the new value while busy | Only the first of each burst | Save, submit, login, "pay now" buttons |
The common mistake is using mergeMap (or a plain nested subscribe()) by default. That's the only one of the four that allows results to arrive out of order, which is exactly the typeahead bug above.
Typeahead: switchMap #
export class ProductSearch {
private http = inject(HttpClient);
query = new FormControl('', { nonNullable: true });
results = toSignal(
this.query.valueChanges.pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap(q =>
q.length < 2
? of([])
: this.http.get<Product[]>('/api/products', { params: { q } }).pipe(
catchError(() => of([])), // keep the stream alive on errors
),
),
),
{ initialValue: [] },
);
}
When "angular" arrives, switchMap unsubscribes from the "ang" request. HttpClient aborts the underlying request when it's unsubscribed, so the stale response never reaches the screen. Put catchError inside the switchMap; on the outer stream, one failed request would end the whole search. Lesson 7.1 covers debouncing in detail.
Save button: exhaustMap #
A double-click on "Save" should not create two records:
private saveClicks = new Subject<void>();
constructor() {
this.saveClicks
.pipe(
exhaustMap(() => this.http.post<Order>('/api/orders', this.form.getRawValue())),
takeUntilDestroyed(),
)
.subscribe(order => this.router.navigate(['/orders', order.id]));
}
save() { this.saveClicks.next(); }
While the POST is in flight, further clicks are dropped. switchMap would be wrong here: cancelling a POST in the browser doesn't undo it on the server, so you could end up with a saved order and no navigation. Disable the button while saving as well; exhaustMap is the guarantee, the disabled button is the feedback.
Upload queue: concatMap #
private files = new Subject<File>();
uploaded = toSignal(
this.files.pipe(
concatMap(file => {
const body = new FormData();
body.append('file', file);
return this.http.post<UploadResult>('/api/uploads', body);
}),
scan((done, result) => [...done, result], [] as UploadResult[]),
),
{ initialValue: [] },
);
addFiles(list: FileList) {
Array.from(list).forEach(f => this.files.next(f));
}
Files upload one at a time in the order they were added. If order doesn't matter and you want throughput, mergeMap(fn, 3) runs up to three uploads in parallel; the second argument limits concurrency.
Row details: mergeMap #
When each request is independent and every result is wanted, such as loading a thumbnail for each item in a list, mergeMap is right. Pass a concurrency limit so you don't open fifty connections at once.
Signal-based loading cancels for you #
resource() and httpResource() track the signals their request depends on. When those signals change while a load is in progress, the resource abandons the stale load:
httpResource()cancels the outstanding HTTP request before issuing the new one.resource()signals its loader'sabortSignalwhen itsparamschange during a load, so afetchthat receives the signal is aborted.
export class OrderDetail {
id = input.required<string>();
// one request per id; switching ids quickly cancels the stale request
order = httpResource<Order>(() => `/api/orders/${this.id()}`);
}
weather = resource({
params: () => ({ city: this.city() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(`/api/weather?city=${encodeURIComponent(params.city)}`, {
signal: abortSignal, // aborted if city() changes mid-request
});
if (!res.ok) throw new Error(`Weather failed: ${res.status}`);
return (await res.json()) as Weather;
},
});
This is switchMap semantics built in, which is why resources are the default choice for reads in modern Angular. They are not meant for writes: Angular's documentation recommends using HttpClient directly for POST, PUT and other mutations, so save and submit flows still use the operator patterns above.
AbortController for hand-written async code #
Outside resources, you can cancel fetch yourself. Keep one controller per "slot" and abort it when a newer request takes over:
private controller?: AbortController;
suggestions = signal<string[]>([]);
async suggest(q: string) {
this.controller?.abort(); // cancel the previous request
const controller = (this.controller = new AbortController());
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, { signal: controller.signal });
this.suggestions.set(await res.json());
} catch (e) {
if ((e as Error).name !== 'AbortError') throw e; // aborts are expected
}
}
Abort the controller on destroy too, with inject(DestroyRef).onDestroy(() => this.controller?.abort()). The same pattern works for anything that accepts an AbortSignal, including addEventListener (the signal option removes the listener when aborted).
Effect cycles and untracked() #
A different kind of race happens inside the reactive graph: an effect reads a signal and writes to another signal that, directly or indirectly, it also reads. Each run triggers the next.
// ✗ reads history() and writes history(): every run schedules another run
effect(() => {
const value = this.value();
this.history.set([...this.history(), value]);
});
untracked() reads a signal without making it a dependency, so the effect runs only when value() changes:
// ✓ depends on value() only
effect(() => {
const value = this.value();
untracked(() => this.history.update(h => [...h, value]));
});
update() doesn't create a dependency on its own, but anything else read inside the effect does, including signals read by functions it calls. Wrapping the "write" part of an effect in untracked() makes that explicit. Before reaching for it, check whether the value should be a computed() or linkedSignal() instead; most effect cycles are derived state written imperatively.
Reproducing a race before you fix it #
Races hide on fast machines and local APIs, so make them visible first. Three techniques work well:
- Throttle the network. In the DevTools Network panel, choose "Slow 4G" or a custom profile with high latency. Type, click and switch tabs quickly. Most ordering bugs appear within a minute.
- Make responses arrive out of order on purpose. In development, add a random delay to the API (or to an interceptor) so later requests sometimes finish first:
export const chaosInterceptor: HttpInterceptorFn = (req, next) =>
isDevMode() ? next(req).pipe(delay(Math.random() * 1500)) : next(req);
- Log a request id. Give each request an incrementing number and log it when it starts and when its result is applied. If the log ever shows result 3 applied after result 4, you have a race, and the log tells you which code path applied it.
Once you can reproduce it, the fix is usually one of the operator or resource changes above, and you can confirm it with the same throttled steps. Keep the chaos interceptor out of production builds; isDevMode() returns false there, but a provider list that only includes it in development is clearer still.
Choosing quickly #
| Scenario | Choose |
|---|---|
| Search box, filters, autocomplete | httpResource() or switchMap |
| Detail page driven by a route param | httpResource() / resource() |
| Save, submit, login, pay | exhaustMap + disabled button |
| Upload queue, ordered autosave | concatMap |
| Independent parallel loads | mergeMap with a concurrency limit |
Hand-written fetch |
AbortController per request slot |
| Effect writing to a signal it depends on | untracked() for the write, or derive with computed() |
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Results for an older query appear last | mergeMap or nested subscribe() |
switchMap or httpResource() |
| Double-click creates two records | mergeMap/switchMap (or no operator) on a save |
exhaustMap and disable the button |
| Search stops working after one error | catchError on the outer stream completes it |
Move catchError inside the switchMap |
| Uploads finish out of order | mergeMap without ordering |
concatMap, or sort results by index |
| Effect runs in a loop | Reads and writes the same signal | untracked() for the write, or computed() |
AbortError in the console |
An aborted fetch rejects |
Catch and ignore errors named AbortError |
What's next #
Lesson 10.7 turns to the router: guards that don't run when you expect. You'll compare canMatch, canActivate, canActivateChild and canDeactivate, see why guards don't re-run on query parameter changes by default, and debug async guards that never complete.
Try it yourself #
Find a search or filter input in your app, open DevTools, and set network throttling to "Slow 4G". Type quickly, then delete a character. If the final results ever don't match the input, find the operator (or nested subscribe) behind it and switch it to switchMap or httpResource().
ai_tutorswitchMap handles races for reads, where only the latest result matters. For writes it’s risky: when a new edit arrives, it unsubscribes from the in-flight PUT. The browser may abort the request, but the server may already have received and applied it, or may receive it after the newer one. So you can’t be sure which version wins. For autosave, use debounceTime(800) to batch keystrokes, then concatMap so each save waits for the previous one and they reach the server in order. If you only care about the final state, the save can always send the full current invoice, so the last request in the queue is authoritative. Show a “Saving…” indicator while the queue isn’t empty, and consider a version number on the invoice so the server can reject stale writes.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


