Caching RxJS Http Observables offline in an Angular 2+ application

Christopher ImrieBy Christopher Imrie on April 25th 2017

Peacock displaying its feathers

Overview

  • Client side caching greatly improve single page application performance, even if only caching for a few minutes.
  • Not always clear how to implement caching fur RxJS observable results in an abstract, reusable way
  • Demonstrates how to implement a local storage and cache service that is RxJS fluent to allow chaining with Angular Http library
  • When used with Service Workers, it can help you achieve offline capable applications

Introduction

A common need in a production ready single page web applications is browser caching of API responses in order to speed up page load time and reduce overall API “chattiness”. When implemented successfully and used in conjunction with Service Workers, you can achieve offline capable web apps (Service Workers can cache APIs too, but are typically more useful for resource caching such as HTML, Images, JS, CSS, etc).

The need for client side caching is especially true for application that start from entirely stateless hosting (i.e. no server side, such as when hosting from an AWS S3 bucket), since all dynamic data has to be fetched on page load, even if the page was refreshed seconds ago. Caching data in the browser, even for a few minutes, can vastly improve the user experience.

Caching API responses is nothing new, but since Angular 2+ uses RxJS for its Http API, its not clear how to achieve client caching in an abstract way that can be reused across different types of requests.

Offline Storage service

First thing an Angular 2+ caching API needs is a storage mechanism. In modern browsers there are many ways you can store data such as localstorage, WebSQL and InnoDB.

Since we want to create a reusable cache for lots of type of data that works in any browser, we’ll use a great third party library (LocalForage) that automatically uses the best storage mechanism for the users browser and also allows storage of many different object types such as strings, integers, objects, buffers etc.

Install LocalForage into your app with NPM:

npm install localforage --save

Next step is to create an Angular service that proxies requests from your app to LocalForage, but in turn also converts the async LocalForage API to using RxJS observables so its fluent with the rest of Angular.

Below is a such a service. Simply drop this into your application and add it to your app.module.ts.

import * as localforage from 'localforage';
import {Injectable} from "@angular/core";
import {Observable} from "rxjs/Observable";

@Injectable()
export class LocalStorageService {

  /**
   *
   * @param key
   * @param value
   * @returns {any}
   */
  public setItem<T>(key:string, value:T):Observable<T>{
    return Observable.fromPromise(localforage.setItem(key, value))
  }

  /**
   *
   * @param key
   * @returns {any}
   */
  public getItem<T>(key:string):Observable<T>{
    return Observable.fromPromise(localforage.getItem(key))
  }

  /**
   *
   * @param key
   * @returns {any}
   */
  public removeItem(key:string):Observable<void>{
    return Observable.fromPromise(localforage.removeItem(key))
  }
}

The LocalForage API is greater than setItem, getItem and removeItem shown above, but this is sufficient for our needs right now. Do check out the LocalForage documentation for more details.

Caching service

With the storage setup, we can go ahead and create the caching service to be use throughout the application.

In terms of API design the following features should let us :

  • Ability to specify a cache key used for storage
  • Ability to specify a cache duration
  • Accepts an observable as an argument and for the service to cache the result
  • Also accepts a flat value
  • Returns Observables for all operations so as to fluently fit in with other Angular services

With the above in mind, and with a firm eye set on making this work with the Angular Http library, the following usage pattern is what we are looking to achieve within and Angular component:

//Inside and Angular component where `this.http` is Angular Http library and `this.cache` is our LocalCacheService 

//Cache a value
let value = 'foo';
this.cache.value('my-cache-key', value, 60).subscribe((value) => {
    //Use value
});

//Cache an observable
let requestObservable = this.http.get("http://example.com/path/to/api").map(res => res.json())

this.cache.observable('my-cache-key', requestObservable, 300).subscribe(result => {
    //Use result
});

The above pattern allows us to use observables returned from the cache service in the same manner as using the Http library.

So with the above pattern in mind, here is the resulting cache service (which has an import dependency on our earlier LocalStarageService and lodash).

import {Injectable} from "@angular/core";
import {LocalStorageService} from "./local-storage.service";
import {Observable} from "rxjs/Observable";
import {isEmpty, isString, isNumber, isDate} from 'lodash';

@Injectable()
export class LocalCacheService {

  /**
   * Default expiry in seconds
   *
   * @type {number}
   */
  defaultExpires: number = 86400; //24Hrs

  constructor(private localstorage: LocalStorageService) {}

  /**
   * Cache or use result from observable
   *
   * If cache key does not exist or is expired, observable supplied in argument is returned and result cached
   *
   * @param key
   * @param observable
   * @param expires
   * @returns {Observable<T>}
   */
  public observable<T>(key: string, observable: Observable<T>, expires:number = this.defaultExpires): Observable<T> {
    //First fetch the item from localstorage (even though it may not exist)
    return this.localstorage.getItem(key)
      //If the cached value has expired, nullify it, otherwise pass it through
      .map((val: CacheStorageRecord) => {
        if(val){
          return (new Date(val.expires)).getTime() > Date.now() ? val : null;
        }
        return null;
      })
      //At this point, if we encounter a null value, either it doesnt exist in the cache or it has expired.
      //If it doesnt exist, simply return the observable that has been passed in, caching its value as it passes through
      .flatMap((val: CacheStorageRecord | null) => {
        if (!isEmpty(val)) {
          return Observable.of(val.value);
        } else {
          return observable.flatMap((val:any) => this.value(key, val, expires)); //The result may have 'expires' explicitly set
        }
      })
  }

  /**
   * Cache supplied value until expiry
   *
   * @param key
   * @param value
   * @param expires
   * @returns {Observable<T>}
   */
  value<T>(key:string, value:T, expires:number|string|Date = this.defaultExpires):Observable<T>{
    let _expires:Date = this.sanitizeAndGenerateDateExpiry(expires);

    return this.localstorage.setItem(key, {
      expires: _expires,
      value: value
    }).map(val => val.value);
  }

  /**
   *
   * @param key
   * @returns {Observable<null>}
   */
  expire(key:string):Observable<null>{
    return this.localstorage.removeItem(key);
  }

  /**
   *
   * @param expires
   * @returns {Date}
   */
  private sanitizeAndGenerateDateExpiry(expires:string|number|Date):Date{
    let expiryDate:Date = this.expiryToDate(expires);

    //Dont allow expiry dates in the past
    if(expiryDate.getTime() <= Date.now()){
      return new Date(Date.now() + this.defaultExpires);
    }

    return expiryDate;
  }

  /**
   *
   * @param expires
   * @returns {Date}
   */
  private expiryToDate(expires:number|string|Date):Date{
    if(isNumber(expires)){
      return new Date(Date.now() + Math.abs(expires)*1000);
    }
    if(isString(expires)){
      return new Date(expires);
    }
    if(isDate(expires)){
      return expires;
    }

    return new Date();
  }
}

/**
 * Cache storage record interface
 */
interface CacheStorageRecord {
  expires: Date,
  value: any
}

The source code for the observable and value methods have comments throughout to explain the logic running through them.

As a nice extra, you’ll notice the code on lines 80-108 are dedicated solely to sanitizing the input date so as to allow method calls to have the option of supplying a number (seconds until expiry), a date object or a string (such as 2017-02-01T10:22:09).

Full example

Here is the full example of the code as a Github Gist:

You can hire me

Contract or consultancy basis, you can benefit from my expertise.

Whether you need full stack development for apps, APIs, Serverless infrastructure, AWS environment development, cloudformation or continuous integration, I can help.

Email