Cache HTTP response in Angular
Typically, if we have multiple same HTTP request in a component, we use rxjs’s share() operator to save API calls. What if we need to do this across multiple components?
This illustrated what happened when we have multiple components that needs to request the same data.
If we inspect the network, we will get the following result. We request for the same data at the same time, but created 3 API calls!
Solution
We will use an interceptor to achieve this. The interceptor will intercept all the same request happens simultaneously, it allows only one of them to make API call, and the others will have to wait for the data.
Full code:
https://raw.githubusercontent.com/erqk/angular-http-cache-interceptor/main/http-cache-interceptor.ts
import {
HttpInterceptorFn,
HttpRequest,
HttpResponse,
} from '@angular/common/http';
import { Subject, filter, finalize, of, tap } from 'rxjs';
const requests = new Map<
string,
{
src: string;
data: HttpResponse<any>;
data$: Subject<HttpResponse<any>>;
params?: any;
ttl?: number;
}
>();
interface CacheOptions {
urlsNotToCache?: string[];
ttls?: { [url: string]: number };
globalTTL?: number;
}
export const httpCacheInterceptor = (options?: CacheOptions) => {
const { urlsNotToCache = [] } = options ?? {};
const fn: HttpInterceptorFn = (req, next) => {
const key = getUniqueKey(req);
const skipCache = urlsNotToCache.some((x) => new RegExp(x).test(req.url));
const prevRequest = () => {
return requests.get(key);
};
const prevReq = prevRequest();
if (!skipCache) {
if (prevReq) {
const { data, data$, ttl } = prevReq;
if (!data$.closed) {
return prevReq.data$;
}
if (data && ttl && ttl > new Date().getTime()) {
return of(prevReq.data);
}
prevReq.data$ = new Subject<any>();
} else {
requests.set(key, {
src: req.url,
data$: new Subject<HttpResponse<any>>(),
data: new HttpResponse<any>(),
params: req.body,
ttl: getTTL(req.url, options),
});
}
}
return next(req).pipe(
filter((x) => x instanceof HttpResponse),
tap((x) => {
const data = x as HttpResponse<any>;
const r = prevRequest();
if (!r) return;
r.data = data;
r.ttl = getTTL(req.url, options);
!r.data$.closed && r.data$.next(data);
}),
finalize(() => {
const r = prevRequest();
r?.data$.complete();
r?.data$.unsubscribe();
}),
);
};
return fn;
};
function getUniqueKey(req: HttpRequest<unknown>): string {
const bodySorted = sortObjectByKeys(req.body);
const key = `${req.method}_${
req.url
}_${req.params.toString()}_${JSON.stringify(bodySorted)}`;
return key;
}
function sortObjectByKeys(obj: any): any {
const keysSorted = Object.keys(obj ?? '').sort();
const objSorted = keysSorted.reduce((_obj, key) => {
const val = obj[key];
_obj[key] = typeof val === 'object' ? sortObjectByKeys(val) : val;
return _obj;
}, {} as any);
return objSorted;
}
function getTTL(reqUrl: string, options?: CacheOptions): number {
const { ttls, globalTTL } = options ?? {};
const getCustomTTL = () => {
const matchedKey = Object.keys(ttls ?? '').find((x) =>
reqUrl.split('?')[0].endsWith(x),
);
if (!ttls || !matchedKey) {
return null;
}
return ttls[matchedKey];
};
return new Date().getTime() + (getCustomTTL() || globalTTL || 0);
}Note: This is functional interceptor that’s introduced after Angular 15. Modify the code if needed.
Then, put the interceptor in the interceptors array. It must be at the last one so that every other interceptors can take advantage of the cache to reduce the trigger of side effects.
provideHttpClient(
withInterceptors([
...
httpCacheInterceptor(),
]),
)update 2024/06/26: The options is added to add more control. Refer to the Playground below to see how they take effects.
Get QK Er’s stories in your inbox
Join Medium for free to get updates from this writer.
Now, let’s inspect the network, we will see there is only one API call!
Explanation
First, we have a Map to store the request data.
const requests = new Map<
string,
{
src: string;
data: HttpResponse<any>;
data$: Subject<HttpResponse<any>>;
params?: any;
ttl?: number;
}
>();For every HTTP request, we check whether the request is already made. If it’s found, we return the Subject() data$ or the HttpResponse data, if the conditions met.
All the subsequent requests will either subscribe to the data$ or data without making any new API call. Otherwise we reset the Subject().
update 2024/06/26: Add ttl
...
const prevRequest = () => {
return requests.get(key);
};
...
if (prevReq) {
const { data, data$, ttl } = prevReq;
if (!data$.closed) {
return prevReq.data$;
}
if (data && ttl && ttl > new Date().getTime()) {
return of(prevReq.data);
}
prevReq.data$ = new Subject<any>();
}
...If this is a new request, we add it to the Map .
...
requests.set(key, {
src: req.url,
data$: new Subject<HttpResponse<any>>(),
data: new HttpResponse<any>(),
params: req.body,
ttl: getTTL(req.url, options),
});
...Later, we feed the Subject() with the value of HTTP response, then we complete it after the HTTP request completes.
We also reset ttl and save the data for later use. (update 2024/06/26)
...
return next(req).pipe(
filter((x) => x instanceof HttpResponse),
tap((x) => {
const data = x as HttpResponse<any>;
const r = prevRequest();
if (!r) return;
r.data = data;
r.ttl = getTTL(req.url, options);
!r.data$.closed && r.data$.next(data);
}),
finalize(() => {
const r = prevRequest();
r?.data$.complete();
r?.data$.unsubscribe();
}),
);
}The complete() and unsubscribe() is necessary because Subject() is a hot observable, all the subscribers will keep waiting for the data until it’s completed.









