Angular/RxJS 6: How to prevent duplicate HTTP requests?

55,377

Solution 1

Based on your simplified scenario, I've built a working example but the interesting part is understanding what's going on.

First of all, I've built a service to mock HTTP and avoid making real HTTP calls:

export interface SomeData {
  some: {
    data: boolean;
  };
}

@Injectable()
export class HttpClientMockService {
  private cpt = 1;

  constructor() {}

  get<T>(url: string): Observable<T> {
    return of({
      some: {
        data: true,
      },
    }).pipe(
      tap(() => console.log(`Request n°${this.cpt++} - URL "${url}"`)),
      // simulate a network delay
      delay(500)
    ) as any;
  }
}

Into AppModule I've replaced the real HttpClient to use the mocked one:

    { provide: HttpClient, useClass: HttpClientMockService }

Now, the shared service:

@Injectable()
export class SharedService {
  private cpt = 1;

  public myDataRes$: Observable<SomeData> = this.http
    .get<SomeData>("some-url")
    .pipe(share());

  constructor(private http: HttpClient) {}

  getSomeData(): Observable<SomeData> {
    console.log(`Calling the service for the ${this.cpt++} time`);
    return this.myDataRes$;
  }
}

If from the getSomeData method you return a new instance, you'll have 2 different observables. Whether you use share or not. So the idea here is to "prepare" the request. CF myDataRes$. It's just the request, followed by a share. But it's only declared once and returning that reference from the getSomeData method.

And now, if you subscribe from 2 different components to the observable (result of the service call), you'll have the following in your console:

Calling the service for the 1 time
Request n°1 - URL "some-url"
Calling the service for the 2 time

As you can see, we have 2 calls to the service, but only one request made.

Yeah!

And if you want to make sure that everything is working as expected, just comment out the line with .pipe(share()):

Calling the service for the 1 time
Request n°1 - URL "some-url"
Calling the service for the 2 time
Request n°2 - URL "some-url"

But... It's far from ideal.

The delay into the mocked service is cool to mock the network latency. But also hiding a potential bug.

From the stackblitz repro, go to component second and uncomment the setTimeout. It'll call the service after 1s.

We notice that now, even if we're using share from the service, we have the following:

Calling the service for the 1 time
Request n°1 - URL "some-url"
Calling the service for the 2 time
Request n°2 - URL "some-url"

Why that? Because when the first component subscribe to the observable, nothing happens for 500ms due to the delay (or the network latency). So the subscription is still alive during that time. Once the 500ms delay is done, the observable is completed (it's not a long lived observable, just like an HTTP request returns only one value, this one too because we're using of).

But share is nothing more than a publish and refCount. Publish allows us to multicast the result, and refCount allows us to close the subscription when nobody is listening to the observable.

So with your solution using share, if one of your component is created later than it takes to make the first request, you'll still have another request.

To avoid that, I cannot think any brilliant solution. Using multicast we'd have to then use the connect method, but where exactly? Making a condition and a counter to know whether it's the first call or not? Doesn't feel right.

So it's probably not the best idea and I'd be glad if someone can provide a better solution there, but in the meantime here's what we can do to keep the observable "alive":

      private infiniteStream$: Observable<any> = new Subject<void>().asObservable();
      
      public myDataRes$: Observable<SomeData> = merge(
        this
          .http
          .get<SomeData>('some-url'),
        this.infiniteStream$
      ).pipe(shareReplay(1))

As the infiniteStream$ is never closed, and we're merging both results plus using shareReplay(1), we now have the expect result:

One HTTP call even if multiple calls are made to the service. No matter how long the first request takes.

Here's a Stackblitz demo to illustrate all of that: https://stackblitz.com/edit/angular-n9tvx7

Solution 2

After trying a few different methods, I came across this one that resolves my issue and only makes one HTTP request no matter how many subscribers there are:

class SharedService {
  someDataObservable: Observable<any>;

  constructor(private http: HttpClient) {}

  getSomeData(): Observable<any> {
    if (this.someDataObservable) {
      return this.someDataObservable;
    } else {
      this.someDataObservable = this.http.get<any>('some/endpoint').pipe(share());
      return this.someDataObservable;
    }
  }
}

I am still open to more efficient suggestions!

For the curious: share()

Solution 3

Even though the solutions proposed by other before work, I find it annoying to have to manually create fields in each class for every different get/post/put/delete request.

My solution is basically based on two ideas: a HttpService that manages all http requests, and a PendingService that manages which requests actually go through.

The idea is to intercept not the request itself (I could have used an HttpInterceptor for that, but it would be too late because the different instances of the requests would have already been created) but the intention of making a request, before it's made.

So basically, all requests go through this PendingService, which holds a Set of pending requests. If a request (identified by it's url) is not in that set, it means this request is new and we have to call the HttpClient method (through a callback) and save it as a pending request in our set, with it's url as key, and the request observable as the value.

If later there's a request made to the same url, we check again in the set using its url, and if it's part of our pending set, it means... that is pending, so we return simply the observable we saved before.

Whenever a pending request is finished, we call a method to delete it from the set.

Here's an example assuming we're requesting... I don't know, chihuahas?

This would be our little ChihuahasService:

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpService } from '_services/http.service';

@Injectable({
    providedIn: 'root'
})
export class ChihuahuasService {

    private chihuahuas: Chihuahua[];

    constructor(private httpService: HttpService) {
    }

    public getChihuahuas(): Observable<Chihuahua[]> {
        return this.httpService.get('https://api.dogs.com/chihuahuas');
    }

    public postChihuahua(chihuahua: Chihuahua): Observable<Chihuahua> {
        return this.httpService.post('https://api.dogs.com/chihuahuas', chihuahua);
    }

}

Something like this would be the HttpService:

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { share } from 'rxjs/internal/operators';
import { PendingService } from 'pending.service';

@Injectable({
    providedIn: 'root'
})
export class HttpService {

    constructor(private pendingService: PendingService,
                private http: HttpClient) {
    }

    public get(url: string, options): Observable<any> {
        return this.pendingService.intercept(url, this.http.get(url, options).pipe(share()));
    }

    public post(url: string, body: any, options): Observable<any> {
        return this.pendingService.intercept(url, this.http.post(url, body, options)).pipe(share());
    }

    public put(url: string, body: any, options): Observable<any> {
        return this.pendingService.intercept(url, this.http.put(url, body, options)).pipe(share());
    }

    public delete(url: string, options): Observable<any> {
        return this.pendingService.intercept(url, this.http.delete(url, options)).pipe(share());
    }
    
}

And finally, the PendingService

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/internal/operators';

@Injectable()
export class PendingService {

    private pending = new Map<string, Observable<any>>();

    public intercept(url: string, request): Observable<any> {
        const pendingRequestObservable = this.pending.get(url);
        return pendingRequestObservable ? pendingRequestObservable : this.sendRequest(url, request);
    }

    public sendRequest(url, request): Observable<any> {
        this.pending.set(url, request);
        return request.pipe(tap(() => {
            this.pending.delete(url);
        }));
    }
    
}

This way, even if 6 different components are calling the ChihuahasService.getChihuahuas(), only one request would actually be made, and our dogs API won't complain.

I'm sure it can be improved (and I welcome constructive feedback). Hope somebody finds this useful.

Solution 4

Late to the party, but I created a reusable decorator specifically to address this use-case. How does it compare to the other solutions posted here?

  • It abstracts all the boilerplate logic out leaving your app's code clean
  • It handles methods with arguments and assures not to share calls to the method with different args.
  • It provides a way to configure when exactly you want to share the underlying observable (see docs).

It's published under an umbrella I'll be using for various Angular-related utilities.

Install it:

npm install @ngspot/rxjs --save-dev

Use it:

import { Share } from '@ngspot/rxjs/decorators';

class SharedService {
  constructor(private http: HttpClient) {}

  @Share()
  getSomeData(): Observable<any> {
    return this.http.get<any>('some/endpoint');
  }
}

Solution 5

There are quite a few approaches already here to help you but I will give you an approach from another prespective.

There is a thing called BehaviorSubject in RxJS which works really well to achieve this. It basically returns the last value right after there is a new subscriber. So you can make the HTTP request when your application loads, and call next() of BehaviorSubject with that value, and from there whenever a subscriber is there, it will instantly return that fetched value instead of making new HTTP requests. You can also re-retrieve the value (when it is updated) by just calling next with the updated value.

More information on BehaviorSubject: https://stackoverflow.com/a/40231605/5433925

Share:
55,377

Related videos on Youtube

patryk0605
Author by

patryk0605

Hey there! Developer here looking to learn and explore. Feel free to message me to chat - I always enjoy meeting new people.

Updated on March 18, 2022

Comments

  • patryk0605
    patryk0605 about 2 years

    Currently have a scenario where a method within a shared service is used by multiple components. This method makes an HTTP call to an endpoint that will always have the same response and returns an Observable. Is it possible to share the first response with all subscribers to prevent duplicate HTTP requests?

    Below is a simplified version of the scenario described above:

    class SharedService {
      constructor(private http: HttpClient) {}
    
      getSomeData(): Observable<any> {
        return this.http.get<any>('some/endpoint');
      }
    }
    
    class Component1 {
      constructor(private sharedService: SharedService) {
        this.sharedService.getSomeData().subscribe(
          () => console.log('do something...')
        );
      }
    }
    
    class Component2 {
      constructor(private sharedService: SharedService) {
        this.sharedService.getSomeData().subscribe(
          () => console.log('do something different...')
        );
      }
    }
    
    • Siri0S
      Siri0S almost 6 years
      I believe, publish is what you're after which is usually combined with refCount(). So getSomeData() method should be: return this.http.get<any>('...').pipe(publish(), refCount());.
    • patryk0605
      patryk0605 almost 6 years
      @Siri0S tried your suggestion however I still see two requests being made in the network tab.
    • Siri0S
      Siri0S almost 6 years
      Well you're right, publishReplay() is what you need. Here is a demo
    • Alex Ward
      Alex Ward almost 6 years
      I've been experiencing a similar thing except somehow the request are being buffered ... I'll be carrying merrily along and then suddenly all my CRUD operations are fired at the api which is creating duplicate objects. Is it possible that observable s could be getting buffered up somehow?
    • Sampgun
      Sampgun over 4 years
      It's been a while, but the correct way to do this is to use a Subject for the data you want to retrieve exposed via set and get.
  • a better oliver
    a better oliver almost 6 years
    There is no need for a subject.
  • maxime1992
    maxime1992 almost 6 years
    Could you fork my stackblitz and provide an example? That's what I thought at first but couldn't achieve that without it.
  • Himanshu Arora
    Himanshu Arora over 5 years
    I used something similar. Didn't find anything else yet
  • Luiz Eduardo
    Luiz Eduardo about 5 years
    I didn't know about share(), it provides the precise behavior I was searching for, thanks!
  • Guntram
    Guntram almost 5 years
    shareReplay(1) did it for me :)
  • Mazen Elkashef
    Mazen Elkashef about 4 years
    By the time I was about to leave the post I noticed the first 2 lines of the comment, so excuse me for the hit and run, however you don't need to update all your services to implement one of the solutions (if you like them), it's best to have a Generic API/REST API service, if you google that you will find plenty of solutions to encapsulate all your API calls in one place where you can implement different features and improvements in one place.
  • Cueball 6118
    Cueball 6118 almost 4 years
    Some may say clunky, but I say genius! It’s unlikely that you have calls from multiple components to the same end point, but in the situations that you do this method is clean and handy in my book and just the ticket. I was having troubles down the line with DB locks and unclosed data readers with two of my many API calls but just targeting those with this pending method was the solution I needed.
  • FindOutIslamNow
    FindOutIslamNow over 3 years
    A note here is why you new Observale ? You could just use of(this.savedResponse); or return the original http.get result
  • Anulal S
    Anulal S over 3 years
    @FindOutIslamNow - this.savedResponse can be an object, string etc & http.get returns an Observable always, hence created new Observable so that the same return type in both cases which makes better code & easy handling on the initiator.
  • hreimer
    hreimer about 3 years
    I would like to test your decorator, but unfortunately I get an error - the packages are definitely installed, what can I do? -> ERROR in The target entry-point "@ngspot/rxjs/decorators" has missing dependencies: - rxjs/operators - rxjs (Angular 9.1.7)
  • Dmitry Efimenko
    Dmitry Efimenko about 3 years
    It looks like you don't have rxjs installed. npm i rxjs. Or use npm7 to install my lib. It installs peer dependencies automatically
  • Xaris Fytrakis
    Xaris Fytrakis over 2 years
    This answer definatelly need more votes. Well done.