Skip to main content

Caching HTTP requests with Angular

6 min read

Older Article

This article was published 8 years ago. Some information may be outdated or no longer applicable.

When working with Angular applications, we often fire HTTP requests to access data from an API. Sometimes those requests hit the same endpoint repeatedly. In that case, caching the response makes sense.

In this article we look at two caching strategies. The first uses a service to cache results. The second uses HTTP Interceptors. I prefer the interceptor approach because it handles caching transparently.

First, let’s see what happens without caching. We’ll create two components, each making 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: an ApiService (an Angular service) 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 each component load fires a request to the API. The screenshot below shows this behaviour, and it happens on every load, not just the first:

non-cached-angular-example

We can update the service to cache the response. Two class member variables come into play, along with Observables.

One member holds the actual response data, the other holds the observable itself. The flow: check if data already exists (meaning the request was made earlier and the data variable is populated). If so, return the data via Observable.of(), which creates an observable sequence we can 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.

We also need to execute the actual HTTP request via HttpClient. Storing this request in a class member means we can return it on subsequent calls instead of firing the request again.

Finally, we gather the data and assign it to the class member.

The code below does exactly that:

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 the request fires only once because we store the response body in the service. Angular services can share data between components, and this is a textbook example. Data sharing between components works with parent-child relationships, and as shown above, it also works between “neighbour” components.

Interceptors

A cleaner way to achieve caching in Angular is through HTTP interceptors. As the name suggests, they let us intercept HTTP messages and alter requests or responses. That makes them ideal for caching. This approach beats the “service only” method because we keep our services clean, no extra caching code bolted on.

With an HTTP interceptor in place, all HTTP requests pass through it, and we can cache at that level.

To keep concerns separated, we’ll create both an interceptor and a caching service. Here’s the interceptor:

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);
        }
      })
    );
  }
}

Despite looking complicated at first glance, the logic is simple. Every class implementing HttpInterceptor must have an intercept method. Inside it, we first try to pull a value from the cache. (The cache is implemented as a service, coming up next.) If we have a cached response, we return an Observable sequence of it. Otherwise, we call sendRequest(), which makes the request and also stores it in the cache for subsequent calls.

Here’s the RequestCache service:

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 get and put methods, working with a Map. The map key is the request URL, the value is the response. There’s also logic to strip out old cache entries, which prevents stale data from lingering.

The last piece: enabling the HTTP interceptor at the module level in app.module.ts:

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 that only concerns itself with making requests. Caching lives in a separate service, and with the interceptor in place, every HTTP request in the application (regardless of where it originates) gets cached automatically.