Unlock the Power of HTTP Request Cancellation in Angular — figure 1

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 cancellation

  • FetchWithRxJs2Component: Example 2 — RxJs with the UI disabled during the loading state

  • FetchWithRxJs3Component: Example 3 — RxJs with automatic request cancellation

  • FetchWithRxJs4Component: Example 4 — RxJs with automatic request cancellation via switchMap()

  • FetchWithRxJs5Component: Example 5 — RxJs combining user-triggered and automatic cancellation via switchMap()

A single shared UI component, UiFetchComponent, is reused by all the FetchWithRxJs*Component examples to render the interface.

The UiFetchComponent

Unlock the Power of HTTP Request Cancellation in Angular — figure 2

The component accepts these inputs:

  • title: string: the heading text displayed in the box

  • httpRequestState: HttpRequestState: the current state of the request

  • message: string: the response body returned by the server

  • hasCancelButton: boolean: defaults to false. When true, a 'Cancel all requests' button is shown

  • isFetchDisabled: boolean: defaults to false. When true, 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:

Unlock the Power of HTTP Request Cancellation in Angular — figure 3

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:

Unlock the Power of HTTP Request Cancellation in Angular — figure 4

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:

Unlock the Power of HTTP Request Cancellation in Angular — figure 5

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 optional

  • The cancelAllRequests(false) function is now invoked from fetchData() 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

When the user clicks the "Fetch" buttons multiple times, all previous requests are now canceled—only the most recent one receives a response:

Unlock the Power of HTTP Request Cancellation in Angular — figure 6

The code can be simplified further using the switchMap() RxJS operator. I applied the following changes (Example 4: FetchWithRxJs4Component):

  • A triggerFetch$ BehaviorSubject is introduced. It handles triggering HTTP requests, and the request-triggering logic is moved into the constructor().

  • The fetchData() function is now just one line: it invokes triggerFetch$.next(...) to initiate the HTTP request

  • The cancelAllRequests() function is no longer needed, as switchMap() 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 true

  • The fetchData() function emits the { path } value through triggerFetch$

  • The cancelAllRequests() function emits the { cancel: true } value through triggerFetch$

  • Inside the switchMap's projector function, if the cancel property 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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!