Angular/RxJS 6: How to prevent duplicate HTTP requests?
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...')
);
}
}
Solution 1:
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 2:
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 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.