DEV Community

Cover image for Unlock the Power of HTTP Request Cancellation in Angular
Gergely Szerovay for This is Angular

Posted on

Unlock the Power of HTTP Request Cancellation in Angular

HTTP request cancellation in Angular means that you abort or cancel an ongoing HTTP request before it gets completed.

Here are a few situations in which request cancellation might be beneficial:

  • In case the user makes multiple similar requests in a short timeframe, canceling unnecessary requests might help us optimize the performance. For example, if our app makes an API call on every key press in a search input field, that might cause unnecessary server load. We can optimize this by canceling previous requests if the user makes an additional keystroke. Our optimization efforts might lead to better response times and reduced server load. (You should also use the debounceTime()RxJs operator in this situation).

  • Consider a scenario in which a user initiates an action, such as clicking a button or navigating away from a page, that triggers an HTTP request. If the user suddenly changes their mind or performs another action, canceling the previous request might prevent unnecessary network traffic and improve the user experience.

  • Generally, when a component level store is destroyed, we should consider canceling the pending requests initiated by the store.

In this article, I demonstrate this by starting with a simple component that fetches data from the server, then I improve it in multiple steps. I explain you:

  • How to avoid race conditions
  • How to automate request cancellation when an additional request arrives
  • How to simplify the cancellation logic with the switchMap() RxJs operator

The full source code is available here:

https://github.com/gergelyszerovay/angular-http-request-cancellation-rxjs

Image description

Application architecture

Backend

You can run the application's backend by yarn run backend or npm run backend. We use the following endpoints:

  • GET /data/2000 : after 2 seconds of delay, it returns the { message: 'You passed 2000 ms' } JSON encoded object

  • GET /data/4000 : after 4 seconds of delay, it returns the { message: 'You passed 4000 ms' } JSON encoded object

  • GET /error : after 2 seconds of delay, it returns a 404 "not found" error

Frontend

I use Angular v16 with standalone components and Tailwind.css. You can run the frontend by yarn run start or npm run start.

The app bootstraps the AppComponent with the following child components:

  • FetchWithRxJs1Component: Example 1: Basic RxJs (with user triggered request cancellation)

  • FetchWithRxJs2Component: Example 2: RxJs (UI is disabled in loading state)

  • FetchWithRxJs3Component: Example 3: RxJs (auto request cancellation)

  • FetchWithRxJs4Component: Example 4: RxJs (auto request cancellation by switchMap())

  • FetchWithRxJs5Component: Example 5: RxJs (user triggered + auto request cancellation by switchMap())

The application has a single UI component: UiFetchComponent, all FetchWithRxJsComponents use this UI component to display the user interface.

The UiFetchComponent component

Image description

The component has the following inputs:

  • title: string: the caption text of the box

  • httpRequestState: HttpRequestState: the request state

  • message: string: the response text we got from the server

  • hasCancelButton: boolean: false by default. If it's true, the box contains a 'Cancel all requests' button

  • isFetchDisabled: boolean: false by default. If it's true, the "Fetch" buttons are disabled

Its outputs are the following:

  • onFetchData<string>: it emits when the user clicks on one of the fetch buttons. The emitted value is the path for the request: '/data/2000' or '/data/4000'

  • onCancel<void>: it emits when the user clicks on the 'Cancel all requests' button

The httpRequestState input contains the request state:

export type HttpRequestState = DeepReadonly<
  'EMPTY' | 'FETCHING' | 'FETCHED' |
  { errorMessage: string }
  >;
Enter fullscreen mode Exit fullscreen mode

Initially, its value is EMPTY. We change it to FETCHING right before we send a request to the server. When the server's response arrives, we set its value to FETCHED. If the server sends an error response or there is an error during the request, we set the request state to an { errorMessage: string } object with the error message.

Requesting data from the server by using the HttpClient.get() method

We use the HttpClient.get() method to fetch data from the server. The method returns an Observable. When we subscribe to this Observable, the HttpClient sends the request to the server. After the response is received from the server, this Observable will emit the server's response, then completes. If there is an error during the request, the Observable emits an error notification.

We can cancel a request by unsubscribing from the Observable returned by HttpClient.get().

In the initial version of our data fetch component (Example 1: FetchWithRxJs1Component), we initiate a request each time the user clicks on one of the "Fetch" buttons. We store the request's Subscriptions in the subscriptions array.

If the user clicks on the "Cancel all requests" button, we unsubscribe from all the requests stored in the subscriptions array (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);
  }
}
Enter fullscreen mode Exit fullscreen mode

For example, if the user clicks twice on the "Fetch from /data/4000 (slower)" button within a short timeframe, then clicks on the "Cancel all requests" button, all the requests get canceled:

Image description

Race conditions

However, user triggered cancellation doesn't protect us from the effects of race conditions and unnecessary requests. For example, if the user clicks on the "Fetch from /data/4000 (slower)" button, then quickly after that on the "Fetch from /data/2000 (fast)" button, we get the server responses in reverse order: first for the /data/2000 request, then for the /data/4000 request, so as a result the "You passed 4000 ms" message is displayed, but we might expect to see the "You passed 2000 ms" message, as the last thing the user interacted with was the "Fetch from /data/2000 (fast)" button. You can see the exact timing of the requests in the "Network" tab of the DevTools:

Image description

We can prevent these race conditions by simply disabling the buttons until our request is pending. We can disable the buttons by using the isFetchDisabled input of our UI component. So this is the improved source code (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>
  `
})
Enter fullscreen mode Exit fullscreen mode

After the user clicks on a "Fetch" button, we disable the buttons until we get the response from the server:

Image description

Automatic request cancellation

When the user for example submits a form, disabling the UI controls might work, but this approach is not viable in many other cases. In our previous example, we had an app that makes an API call on every key press in a search input field. If we disabled the input field after every keystroke, it would lose its focus and the control would become unusable.

To apply a different approach, we can automatically cancel the pending requests if the user clicks on one of the "Fetch" buttons. I refactor the component (Example 3: FetchWithRxJs3Component):

  • I add a new parameter to the cancelAllRequests() function to make the state update optional

  • I call the cancelAllRequests(false) function from the fetchData() function to cancel all previous 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

Now if the user clicks on the "Fetch" buttons multiple times, we can see that we canceled all the previous requests, only the last request gets a response:

Image description

We can simplify this code by using the switchMap() RxJs operator. I make the following changes in the code (Example 4: FetchWithRxJs4Component):

  • I introduce the triggerFetch$ BehaviorSubject. It triggers the HTTP request. I move the request triggering logic into the constructor().

  • The fetchData() function is now just a single line, it calls triggerFetch$.next(...) to trigger the HTTP request

  • We don't need the cancelAllRequests() function anymore, as the switchMap() operator 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

How does the switchMap() operator work?

When the user clicks on one of the "Fetch" buttons, we emit a value through the triggerFetch$ BehaviorSubject. The switchMap() gets this value through the pipe(), and initiates a new HTTP request by subscribing to the Observable returned by HttpClient.get().

Now, if the user clicks on one of the "Fetch" buttons again, switchMap() gets a new value from triggerFetch$, so it automatically unsubscribes from the previous Observable and starts a new HTTP request by subscribing to the Observable returned by HttpClient.get().

What if we would like to support user canceling, too?

To add user canceling, I change (Example 5: FetchWithRxJs5Component):

  • The data type of triggerFetch$ to { path?: string, cancel?: boolean }. This way we can both start a new request or cancel 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, we return an empty Observable that 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’m going to explain how we can handle HTTP request cancellation inside an NgRx ComponetStore or SignalStore.


👨‍💻About the author

My name is Gergely Szerovay, I work as a frontend development chapter lead. Teaching (and learning) Angular is one of my passions. I consume content related to Angular on a daily basis — articles, podcasts, conference talks, you name it.

I created the Angular Addict Newsletter so that I can send you the best resources I come across each month. Whether you are a seasoned Angular Addict or a beginner, I got you covered.

Next to the newsletter, I also have a publication called — you guessed it — Angular Addicts. It is a collection of the resources I find most informative and interesting. Let me know if you would like to be included as a writer.

Let’s learn Angular together! Subscribe here 🔥

Follow me on Medium, Twitter or LinkedIn to learn more about Angular!

Top comments (2)

Collapse
 
chetanam profile image
Chetan

Great

Collapse
 
nivek profile image
nivek

Top!