Angular Caching Strategies - JU-DEV-Bootcamps/ERAS GitHub Wiki
Caching is a crucial strategy to improve performance in an Angular application. Whether it is for storing expensive computations or saving the results of network requests, caching allows an application to provide faster responses and an enhanced user experience.
There are several caching strategies for a frontend application. In this wiki article we will explore two approaches implemented in ERAS application.
By using shareReplay operator we can ensure expensive operations like HTTP requests are executed only once as subsequent subcribers will receive the cached value immediately without retriggering the underlying logic.
In ERAS, this approach is used for caching HTTP requests responses. To implement it:
- Create a property in the service class of type
Observable<T> | null. This property will store the Observable result of the request and will be referenced in future calls.
private cache$: Observable<PagedResult<JuService>> | null = null;
- In the method to be cached, add a
pipeto the requests returning an Observable and add theshareReplayoperator. You can set the configurations as needed, but default is{ bufferSize: 1, refCount: false }. This will allow future subscribers to get the latest emitted value (bufferSize) forever (refCount).
this.cache$ = this.get<PagedResult<JuService>>('', params).pipe(
shareReplay({ bufferSize: 1, refCount: false })
);
- Before the actual call to the expensive method returning the piped Observable, validate if the property storing the Observable has any value. If it does, return the property. This will prevent the application to start a new request while still providing the data to the component or service.
if (this.cache$) {
return this.cache$;
}
- Implement a method to invalidate cached data. Setting the value of the property to
nullis generally enough. This prevents data to become stale as the data source might be updated any time during the app lifecycle.
invalidateCache(): void {
this.cache$ = null;
}
This approach is simple and effective for simple scenarios but could become cumbersome to maintain as it requires several changes in multiple methods. Also, it requires boilerplate code for storing other methods responses within the same service.
Decorators provide a way to add annotations and extend behaviors of methods and classes in TypeScript. In this sense, a custom decorator to make a method cacheable is an alternative for generalizing caching in an Angular-based frontend application.
In ERAS, the custom @Cacheable decorator can be added to any method to store its result in memory. It depends on the CacheService that handles the setting and getting of cached data in a Map stored in the _cachedData property.
private _cachedData = new Map<string, { data: unknown; timestamp: number }>();
The CacheService also handles the caching invalidation using methods to remove a specific piece of information from the _cachedData or to clear all cached data. Also, it implements a Time-To-Live based validation to automatically invalidate data after a set timeframe upon requests.
To implement this approach:
- Implement the
CacheableHostinterface on the class where the method you want to make cacheable is defined. In sum, this interface makes sure that theCacheServiceis injected into the class so the decorator can reference it.
export interface CacheableHost {
cacheService: CacheService;
}
- Decorate the method with the
@Cacheabledecorator. If you want to add a custom key to identify the cached data, pass the custom key as a string to the decorator. If no argument is provided, the name of the method is used as the key.
@Cacheable('students')
getAllStudents() {
In the background, the @Cacheable decorator tries to get the cached data from the CacheService using the custom or default key. If it gets it, it returns the stored value; if not, then it executes the function and stores the result in the CacheService for future calls.
function Cacheable(customKey?: string) {
return function (
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalFunction = descriptor.value;
const cacheKey = customKey ?? propertyKey;
descriptor.value = function (this: CacheableHost, ...args: unknown[]) {
const cacheService = this.cacheService;
if (!cacheService) {
console.warn(
`"${propertyKey}" requires a "cacheService" property on the instance. Inject CacheService.`
);
return originalFunction.apply(this, args);
}
const cachedValue = cacheService.getCachedData(cacheKey);
if (cachedValue !== null) {
return cachedValue;
}
const result = originalFunction.apply(this, args);
// Use shareReplay to avoid triggering request on new subscriptions.
if (result instanceof Observable) {
const sharedObservable = result.pipe(
shareReplay({ bufferSize: 1, refCount: false })
);
cacheService.setCachedData(cacheKey, sharedObservable);
return sharedObservable;
}
// Non-observable cases.
cacheService.setCachedData(cacheKey, result);
return result;
};
};
}
This approach is simple and can be used in different services and methods without much boilerplate code. It also allows cached data to be available throughout the application and provides methods to invalidate data from anywhere within the application. However, it is intended to be used primarily on methods without arguments to prevent data inconsistencies with requests that highly differ depending on the arguments provided.
It is possible to extend the behavior to store the results of parameterized methods if needed, although it would require to define a clear strategy to define and make available the keys to identify the data.
A mixed approach is useful for ERAS as the @Cacheable decorator can be used for methods intended to get all the data from a resource and RxJs-based approaches allow for caching results of specific parameterized methods.