Project structure
Server-side setup
The backend for the demo can be launched with yarn run backend or npm run backend. It exposes these endpoints:
GET /data/2000: waits 2 seconds, then returns the JSON object{ message: 'You passed 2000 ms' }GET /data/4000: waits 4 seconds, then returns the JSON object{ message: 'You passed 4000 ms' }GET /error: waits 2 seconds, then responds with a 404 "not found" error
Client-side setup
The frontend is built with Angular v16, standalone components, and Tailwind.css. Start it with yarn run start or npm run start.
The root AppComponent bootstraps these child components:
FetchWithRxJs1Component: Example 1 — Basic RxJs with user-triggered request cancellationFetchWithRxJs2Component: Example 2 — RxJs with the UI disabled during the loading stateFetchWithRxJs3Component: Example 3 — RxJs with automatic request cancellationFetchWithRxJs4Component: Example 4 — RxJs with automatic request cancellation viaswitchMap()FetchWithRxJs5Component: Example 5 — RxJs combining user-triggered and automatic cancellation viaswitchMap()
A single shared UI component, UiFetchComponent, is reused by all the FetchWithRxJs*Component examples to render the interface.
The UiFetchComponent

The component accepts these inputs:
title: string: the heading text displayed in the boxhttpRequestState: HttpRequestState: the current state of the requestmessage: string: the response body returned by the serverhasCancelButton: boolean: defaults tofalse. Whentrue, a 'Cancel all requests' button is shownisFetchDisabled: boolean: defaults tofalse. Whentrue, the "Fetch" buttons are disabled
It exposes these outputs:
onFetchData<string>: emitted when a fetch button is clicked. The value is the request path: either'/data/2000'or'/data/4000'onCancel<void>: emitted when the 'Cancel all requests' button is clicked
The httpRequestState input tracks the lifecycle of the request:
export type HttpRequestState = DeepReadonly<
'EMPTY' | 'FETCHING' | 'FETCHED' |
{ errorMessage: string }
>;
It starts as EMPTY. Right before the request is dispatched, it becomes FETCHING. Once the server responds, it flips to FETCHED. If the server returns an error or the request fails, the state becomes { errorMessage: string } holding the error text.
Fetching with HttpClient.get()
All data is retrieved via the HttpClient.get() method, which returns an Observable. Subscribing to it triggers the network request. When the server responds, the Observable emits the payload and then completes; on failure, it emits an error notification.
Unsubscribing from the Observable returned by HttpClient.get() cancels the underlying request.
In the first iteration (Example 1: FetchWithRxJs1Component), a new request is launched on every click of a fetch button. Each subscription is tracked in the subscriptions array.
When the "Cancel all requests" button is clicked, we loop through the stored subscriptions and unsubscribe from each one via the cancelAllRequests() function:
// fetch-with-rxjs1.component.ts
@Component({
selector: 'app-fetch-with-rxjs1',
// ...
template: `
<app-ui-fetch-component style="display: block"
[httpRequestState]="httpRequestState$ | async"
[message]="message$ | async"
[hasCancelButton]="true"
title="Example 1: Basic RxJs"
(onFetchData)="fetchData($event)"
(onCancel)="cancelAllRequests()"
>
</app-ui-fetch-component>
`
})
export class FetchWithRxJs1Component {
private http = inject(HttpClient);
protected httpRequestState$ = new BehaviorSubject<HttpRequestState>('EMPTY');
protected message$ = new BehaviorSubject<string | null>(null);
protected subscriptions: Subscription[] = [];
cancelAllRequests() {
if (!this.subscriptions) {
return;
}
this.subscriptions.forEach(s => s.unsubscribe());
this.subscriptions = [];
this.httpRequestState$.next({ errorMessage: 'All requests were canceled' });
}
fetchData(path: string) {
const url = `http://localhost:3000/${path}`;
console.log('http.get', url);
this.httpRequestState$.next('FETCHING');
const subscription = this.http.get<MessageResponseType>(url)
.pipe(
catchError((errorResponse: HttpErrorResponse) => {
this.httpRequestState$.next({ errorMessage: 'Request error' });
return EMPTY;
}),
).subscribe((response) => {
this.message$.next(response.message);
this.httpRequestState$.next('FETCHED');
});
this.subscriptions.push(subscription);
}
}
So if the user clicks "Fetch from /data/4000 (slower)" twice in quick succession and then hits "Cancel all requests", both outstanding requests are terminated:

Handling race conditions
Manual cancellation alone does not guard against race conditions or redundant network traffic. For instance, suppose the user clicks "Fetch from /data/4000 (slower)" and immediately afterwards "Fetch from /data/2000 (fast)". The responses will arrive in reverse order: the /data/2000 response comes first, then the /data/4000 response. The UI ends up showing the "You passed 4000 ms" message, even though the user's last action was selecting the faster endpoint. The DevTools "Network" tab reveals the exact timing of both requests:

A straightforward fix is to disable the fetch buttons while a request is in flight, which prevents overlapping calls. The isFetchDisabled input on the UI component accomplishes this. Here is the revised implementation (Example 2: FetchWithRxJs2Component):
// fetch-with-rxjs2.component.ts
@Component({
// ...
template: `
<app-ui-fetch-component
[httpRequestState]="httpRequestState$ | async"
[message]="message$ | async"
[hasCancelButton]="true"
[isFetchDisabled]="(httpRequestState$ | async) === 'FETCHING'" 👈
title="RxJs (UI is disabled in loading state)"
(onFetchData)="fetchData($event)"
(onCancel)="cancelAllRequests()"
>
</app-ui-fetch-component>
`
})
Once the user clicks a fetch button, the buttons stay disabled until the server responds:

Canceling Requests Automatically
Disabling UI controls when a form is submitted can work in certain scenarios, but it falls short in others. Consider the search input example from earlier: every keystroke triggers an API call. If we were to disable the input after each keypress, the field would lose focus and become effectively unusable.
An alternative strategy is to automatically cancel any pending requests whenever the user clicks one of the "Fetch" buttons. Here's how I refactored the component (Example 3: FetchWithRxJs3Component):
A new parameter was added to the
cancelAllRequests()function, allowing the state update to be optionalThe
cancelAllRequests(false)function is now invoked fromfetchData()to cancel all prior requests
// fetch-with-rxjs3.component.ts
export class FetchWithRxJs3Component {
// ...
cancelAllRequests(updateState = true) {
if (!this.subscriptions) {
return;
}
this.subscriptions.forEach(s => s.unsubscribe());
this.subscriptions = [];
if (updateState) {
this.httpRequestState$.next({ errorMessage: 'All requests were canceled' });
}
}
fetchData(path: string) {
const url = `http://localhost:3000/${path}`;
console.log('http.get', url);
this.cancelAllRequests(false);
this.httpRequestState$.next('FETCHING');
const subscription = this.http.get<MessageResponseType>(url)
.pipe(
catchError((errorResponse: HttpErrorResponse) => {
this.httpRequestState$.next({ errorMessage: 'Request error' });
return EMPTY;
}),
).subscribe((response) => {
this.message$.next(response.message);
this.httpRequestState$.next('FETCHED');
});
this.subscriptions.push(subscription);
}
}
When the user clicks the "Fetch" buttons multiple times, all previous requests are now canceled—only the most recent one receives a response:
The code can be simplified further using the switchMap() RxJS operator. I applied the following changes (Example 4: FetchWithRxJs4Component):
A
triggerFetch$BehaviorSubjectis introduced. It handles triggering HTTP requests, and the request-triggering logic is moved into theconstructor().The
fetchData()function is now just one line: it invokestriggerFetch$.next(...)to initiate the HTTP requestThe
cancelAllRequests()function is no longer needed, asswitchMap()automatically cancels the previous request
// fetch-with-rxjs4.component.ts
export class FetchWithRxJs4Component {
// ...
protected triggerFetch$ = new BehaviorSubject<string>('');
cancelAllRequests(updateState = true) {
if (!this.subscriptions) {
return;
}
this.subscriptions.forEach(s => s.unsubscribe());
this.subscriptions = [];
if (updateState) {
this.httpRequestState$.next({ errorMessage: 'All requests were canceled' });
}
}
constructor() {
this.triggerFetch$.pipe(
skip(1), // we doesn't want an initial fetch on component creation
takeUntilDestroyed(),
tap(() => {
this.cancelAllRequests(false);
this.httpRequestState$.next('FETCHING')
}),
switchMap((path) => {
const url = `http://localhost:3000/${path}`;
console.log('http.get', url);
return this.http.get<MessageResponseType>(url)
.pipe(
catchError((errorResponse: HttpErrorResponse) => {
this.httpRequestState$.next({ errorMessage: 'Request error' });
return EMPTY;
})
);
})).subscribe((response) => {
this.message$.next(response.message);
this.httpRequestState$.next('FETCHED');
});
}
fetchData(path: string) {
this.triggerFetch$.next(path);
}
}
The inner workings of switchMap()
When the user clicks one of the "Fetch" buttons, a value is emitted through the triggerFetch$ BehaviorSubject. The switchMap() operator receives this value via the pipe() and initiates a fresh HTTP request by subscribing to the Observable returned by HttpClient.get().
If the user clicks one of the "Fetch" buttons again, switchMap() receives a new value from triggerFetch$. At that point, it automatically unsubscribes from the prior Observable and launches a new HTTP request by subscribing to the Observable from HttpClient.get().
What about supporting manual cancellation as well?
To incorporate user-triggered cancellation, I made these adjustments (Example 5: FetchWithRxJs5Component):
The data type of
triggerFetch$is changed to{ path?: string, cancel?: boolean }. This enables both starting a new request and canceling the previous one by setting the cancel property to trueThe
fetchData()function emits the{ path }value throughtriggerFetch$The
cancelAllRequests()function emits the{ cancel: true }value throughtriggerFetch$Inside the
switchMap's projector function, if thecancelproperty of the input value is true, an empty Observable is returned, which cancels the previous request
// fetch-with-rxjs5.component.ts
export class FetchWithRxJs5Component {
// ...
protected triggerFetch$ = new BehaviorSubject<{ path?: string, cancel?: boolean }>(
{ path: '' });
constructor() {
this.triggerFetch$.pipe(
skip(1), // we doesn't want an initial fetch on component creation
takeUntilDestroyed(),
tap(() => this.httpRequestState$.next('FETCHING')),
switchMap(({ path, cancel }) => {
if (cancel) { // 👈
return of();
}
const url = `http://localhost:3000/${path}`;
console.log('http.get', url);
return this.http.get<MessageResponseType>(url)
.pipe(
tap((response) => {
this.message$.next(response.message);
this.httpRequestState$.next('FETCHED');
}),
catchError((errorResponse: HttpErrorResponse) => {
this.httpRequestState$.next({ errorMessage: 'Request error' });
return EMPTY;
})
);
})).subscribe();
}
cancelRequests() {
this.triggerFetch$.next({ cancel: true });
}
fetchData(path: string) {
this.triggerFetch$.next({ path });
}
}
In the second part of this article series, I'll cover how to handle HTTP request cancellation within an NgRx ComponentStore or SignalStore.
👨💻About the author
I'm Gergely Szerovay, working as a frontend development chapter lead. Teaching and learning Angular is a genuine passion of mine. I'm constantly consuming Angular-related content—articles, podcasts, conference talks, and more.
I launched the Angular Addict Newsletter to share the best resources I discover each month. Whether you're a seasoned Angular Addict or just starting out, you'll find something valuable.
In addition to the newsletter, I run a publication aptly named Angular Addicts. It's a curated collection of the most informative and interesting resources I come across. Feel free to reach out if you'd like to contribute as a writer.
Let's learn Angular together! Subscribe here 🔥
Connect with me on Medium, Twitter, or LinkedIn to keep learning about Angular!

