Caching HTTP requests with Angular

This post is 4 years old. (Or older!) Code samples may not work, screenshots may be missing and links could be broken. Although some of the content may be relevant please take it with a pinch of salt.

Often when working with Angular applications, we make HTTP requests to access data from an API. Sometimes we are making requests to the same API endpoint in which case it'd make sense to cache the result of the response to avoid further requests.

In this article, we visit two strategies that can be used to cache the response from the APIs. In one, we'll be using a service to cache the results of the response, and in a second solution, we'll be using HTTP Interceptors. Personally, I like the second approach, using the HTTP Interceptors because it gives us a transparent way to handle caching.

First, let's take a look at what happens when we are not using caching in the application. For this tutorial we are creating two components, each will make an HTTP request to the same API.

You can use any API that you want and even create your own.

Please note that the examples below are using the Angular HTTPClient.

The setup is that we'll have an ApiService (an Angular service) that will be responsible for making the HTTP request:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable()
export class ApiService {
constructor(private http: HttpClient) {}

getProducts() {
return this.http.get('/api/products');
}
}

Accessing the service from two separate components means that each time we load a component, a request is made to the API. The screenshot below demonstrates this behaviour. And it's not only an initial load but on all consequent loads:

non-cached-angular-example

We can update the service and make sure that the response is cached. To achieve this, we'll introduce two class member variables as well as utilise Observables.

One class member is going to hold the actual response data while the other is going to keep the observable itself. The flow is the following: check if data already exists (i.e. the request has been made at some point, and therefore we have the data variable filled with the response data). If this case is valid, we can safely return the data itself, and we need to use Observable.of() to do this. Observable.of() creates an observable sequence which we can then subscribe to.

The simplest form of Observable.of() would be: const observable = Observable.of(0, 1, 2);. This creates the observable variable on which we can now subscribe.

Then we need a mechanism to execute the actual HTTP request, for this purpose we'll stick to the HttpClient. It makes sense to store this request in a class member as well so that if we have this request available, we can return that instead and not make the HTTP request again.

And last but not least we also need to gather the data and assign it to the class member.

The below code achieves precisely what we discussed above.

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/map';
// more code

data;
observable;

getProducts() {
if (this.data) {
return Observable.of(this.data);
} else if (this.observable) {
return this.observable;
} else {
this.observable = this.http.get('/api/products', {
observe: 'response'
})
.map(response => {
this.observable = null;
if (response.status === 400) {
return 'Request failed.';
} else if (response.status === 200) {
this.data = response.body;
return this.data;
}
})
.share();
return this.observable;
}
}

Take a look at the second parameter of the GET request. We send the observe: 'response' option to receive not only the data but additional information about the request such as the status, which we use to do some extra checks. This is optional, but I found this to be a useful addition.

service-cached-angular-exampe

Notice that in this case, we make the request only once as we store the response body in the service. Services in Angular can be used to share data between components, and this is a perfect example of that. In fact, data sharing between components is possible if there's a parent-child relationship between the components and, as seen in the case above, it's also possible to share data between "neighbour" components.

Interceptors

A better way to achieve caching in an Angular application would be to use HTTP interceptors. HTTP interceptors - as their name suggests - allow us to intercept HTTP messages, and we can alter the requests or the responses. This makes it ideal for using them to cache requests. This approach is better than the previously discussed "service only" based approach because this way, we can keep our service(s) as they are, that is, we don't need to pollute it with extra code.

Since an HTTP interceptor is going to be in place, all HTTP requests will go through the interceptor, and we can do the caching at that level. Sounds great? Let's take a look at how this would be achieved.

To keep the separation of concerns we'll create an interceptor and a service as well that will do the caching. Let's take a look at the interceptor first.

import { Injectable } from '@angular/core';
import {
HttpEvent,
HttpRequest,
HttpResponse,
HttpInterceptor,
HttpHandler,
} from '@angular/common/http';

import { Observable } from 'rxjs/Observable';
import { startWith, tap } from 'rxjs/operators';
import 'rxjs/add/observable/of';

import { RequestCache } from './request-cache.service';

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
constructor(private cache: RequestCache) {}

intercept(req: HttpRequest<any>, next: HttpHandler) {
const cachedResponse = this.cache.get(req);
return cachedResponse
? Observable.of(cachedResponse)
: this.sendRequest(req, next, this.cache);
}

sendRequest(
req: HttpRequest<any>,
next: HttpHandler,
cache: RequestCache
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.put(req, event);
}
})
);
}
}

Even though the code above may look complicated at first, it's very straightforward. All classes that implement the HttpInterceptor must have an intercept method. In this intercept method, we go and first try to get a value from our cache. (The cache is implemented as a service which we'll take a look at soon.) If we have a cached response for the request that we are intercepting we return an Observable sequence of the cached version, otherwise we call the sendRequest() method which makes the request and also puts the request to the cache - so that any subsequent request can be served from the cache.

Let's take a look at the RequestCache service as well.

import { Injectable } from '@angular/core';
import { HttpRequest, HttpResponse } from '@angular/common/http';

const maxAge = 30000;
@Injectable()
export class RequestCache {
cache = new Map();

get(req: HttpRequest<any>): HttpResponse<any> | undefined {
const url = req.urlWithParams;
const cached = this.cache.get(url);

if (!cached) {
return undefined;
}

const isExpired = cached.lastRead < Date.now() - maxAge;
const expired = isExpired ? 'expired ' : '';
return cached.response;
}

put(req: HttpRequest<any>, response: HttpResponse<any>): void {
const url = req.url;
const entry = { url, response, lastRead: Date.now() };
this.cache.set(url, entry);

const expired = Date.now() - maxAge;
this.cache.forEach((expiredEntry) => {
if (expiredEntry.lastRead < expired) {
this.cache.delete(expiredEntry.url);
}
});
}
}

Please note that the above code sample is based on the Angular RequestCache example.

The RequestCache service implements a get and a put method, and it works with a map. The map will have an entry where the key will the actual request, and the value is going to be the response. Notice that there's also some functionality to remove old cache entries from the cache map. This is useful to avoid having stale data in the cache.

The last part of the process is to enable the HTTP interceptor. This can be done at the module level - in the app.module.ts file.

import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { RequestCache } from './request-cache.service';
import { CachingInterceptor } from './caching-interceptor.service';

// ...

providers: [
RequestCache,
{ provide: HTTP_INTERCEPTORS, useClass: CachingInterceptor, multi: true }
],

// ...

The result is a much cleaner ApiService, which only concerns itself with making the actual request and it's not dealing with caching. Caching is done in a separate service, and with the help of HTTP interceptors, we can make sure that every single HTTP request that we do (regardless where they are found in the application) will be cached.