Client side caching of API responses

Talk about caching and we talk about how we are going toĀ put everything into redis or memcache on our servers and send back cached responses to ourĀ clients and make our applications respondĀ faster by taking the load off the databases. Below is a flow of how a typical cachingĀ mechanismĀ looks like.

Client Server with Cache
Client Server with Cache

While server caching is a phenomenal way of improving the response time butĀ we still have toĀ serve the responses whether we hit the database or not. The client requests still reach our application servers, be understood, sent to the appropriate handlers, processed, checked for the availability in cache, response created and sent back. So the Ā scenario when our request goes from client to cache server and comes back looks something like this.

Data Flow Client Server with Cache
Data Flow from Client to Server to Client with Server Cache

If you take a good look at your API calls, you would notice that some of the requests are just redundant and gets called onĀ refresh or onĀ all pages. A good high performing system would not only takeĀ the load off the databases but also off the application servers. The way to do that would be to stopĀ making duplicate requests to theĀ application servers by caching theĀ responses in theĀ client itself. You may store the data inĀ localStorage, sessionStorage or cookies as per the needs of your application.

Client side cache system
Client side cache system

One of the applications we are working on had a similar problem. We were making multiple calls to theĀ same route on every page. The APIs wereĀ called by our directives to load the data into itsĀ select box. We cannot avoidĀ those calls because they wereĀ necessary but since they were redundant we came up with our own client side caching system.

Here is a snapshot of the network tabĀ after navigating through 3 pages without client side cache.

Network tab showing redundant multiple API calls
Network tab showing redundant multiple API calls. The colors showing duplicates.

Below is a snapshot of the network tab after navigating through the sameĀ 3Ā pages but this time with client side cache enabled.

Network tab snapshot after enabling client side cache
Network tab snapshot after enabling client side cache

The API calls almost halved and theĀ page load is fasterĀ now.

The caching system

To make sure we don’t touch any of our existing client side code and still be able to implement the caching system we wrote listeners for our http requests by overriding theĀ XMLHttpRequest open and send method.Ā So basically making sure that all GET requests go through our overridden open method and all the responses to APIs that we intend to cache gets cached.

This is how we used to call our API previously,

//Our old API call
ApiService.getAll('subject-types').then(function(response) {
      scope.subjectTypes = response.data;
});

And now we call it with an extra parameter.

//Our new API call
ApiService.getAll('subject-types', true).then(function(response) {
      scope.subjectTypes = response.data;
});

the true passed into the method tells the method that this API call needs to be cached. This is what the getAll method looks like in the APIService.

/**
     * 
     * @param  {[type]} routeprefix - API url
     * @param  {[type]} cacheit - Wether this API needs to be cached or not
     * @param  {[type]} urlsthatwillaffectthisget - the APIs that will effect the state of the cached data of this API
     * 
     */
    function getAll(routeprefix, cacheit, urlsthatwillaffectthisget) {
        var url = '/api/' + routeprefix;
        if (cacheit) {
            apicache.register(url, urlsthatwillaffectthisget);
        }
        var cacheddata = apicache.get(url);
        if (cacheddata) {
            if(window.debug){
                console.info("data from cache", url);
            }
            return new Promise(function(resolve, reject) {
                resolve({
                    "data": cacheddata
                });
            });
        } else {
            return $http({
                method: 'GET',
                url: '/api/' + routeprefix
            });
        }
    }

As you can see in the code, I am checking if theĀ API needs to be cached, if yes then registering a listener for it.

Whatever goes inside cache may change in the server when we add, update or delete aĀ record. In that case we may have to delete the cached information so that we can get the new value. To make that happen we need to identify the API calls that effect the stored value, this is where our third parameterĀ urlsthatwillaffectthisget comes into picture. Take a look at the code below.

var urlthataffecthisgetroute = ['students/{{id}}/academics/{{id}}','students/{{id}}/academic'];//one save and one put

ApiService.getAll('students/' + studentId + '/academics/' + studentAcademicId, true, urlthataffecthisgetroute)
.then(function(response) {
      scope.academic = response.data;
);

We do not know what will be the exact PUT or DELETE urls, so we simply provide the url signature with UUIDs as {{id}}. So a match of this URL call will automatically delete the stored key from the client storage. If there is no third parameter then its assumed that there is no other API change affecting this value, so it will not change throughout the user session.

The full caching module code is as below.

/**
 * API Response Caching System
 * This module creates a global variable called 'apicache' and makes it available throughout.
 */
(function() {

    /**
     * 
     * Variables to store our XMLHttpRequest object methods
     */
    var open = window.XMLHttpRequest.prototype.open,
        send = window.XMLHttpRequest.prototype.send,
        onReadyStateChange;

    /**
     * The prefix helps in keeping our keys unique making sure it doesn't conflict with the other keys
     */
    var _keyprefix = "_storagekey_";

    /**
     * The regex to replace all my UUIDs to some other more easily storable format
     */
    var uuid_regex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;

    /**
     * XMLHttpRequest object
     */
    var xmlhttp = window.XMLHttpRequest;

    var url;

    /**
     * Method that overrides our XMLHttpRequest open method
     * 
     * @param  String method GET,POST,PUT,DELETE etc
     * @param  String url    API url
     * @param  String async  sync or async
     */
    function openReplacement(method, url, async) {
        var syncMode = async !== false ? 'async' : 'sync';
        this.url = url;
        return open.apply(this, arguments);
    }

    /**
     * Method that overrides our XMLHttpRequest send method
     * 
     * @param  Object data object passed as payload
     * 
     */
    function sendReplacement(data) {
        //console.log('Sending HTTP request data : ', data);

        if (this.onreadystatechange) {
            this._onreadystatechange = this.onreadystatechange;
        }
        this.onreadystatechange = onReadyStateChangeReplacement;
        return send.apply(this, arguments);
    }

    /**
     * Method thats called when the response is recived from the server
     */
    function onReadyStateChangeReplacement() {
        //console.log('HTTP request ready state changed : ' + this.readyState);
        //console.log(this.responseText);
        if (this.status == 200) {
            //store only if the route returns success
            storeIntoCache(this.url, this.responseText);
        }
        if (this._onreadystatechange) {
            return this._onreadystatechange.apply(this, arguments);
        }
    }

    /**
     * Store data into cache. url as key and responseText as value
     */
    function storeIntoCache(url, responseText) {
        if (urlRegisteredForCache(url)) {
            sessionStorage.setItem(_keyprefix + url, responseText);
        }
    }

    /**
     * Get From Cache using the URL as key. 
     */
    function getFromCache(url) {
        return JSON.parse(sessionStorage.getItem(_keyprefix + url));
    }

    function urlRegisteredForCache(url) {
        var getroutes_tocache = JSON.parse(sessionStorage.getItem('getroutes_tocache'))
        if (getroutes_tocache && getroutes_tocache.indexOf(url) > -1) {
            return true;
        }
        return false;
    }

    function dataCachedInStorage(url) {
        return sessionStorage.getItem(_keyprefix + url);
    }

    //Overriding
    window.XMLHttpRequest.prototype.open = openReplacement;
    window.XMLHttpRequest.prototype.send = sendReplacement;

    function getstoredgetroutes() {
        return JSON.parse(sessionStorage.getItem('getroutes_tocache'));
    }

    //We create our Cache class 
    function Apicache() {

    }

    //get data from cache
    Apicache.prototype.get = function(url) {
        return JSON.parse(sessionStorage.getItem(_keyprefix + url));
    }

    //delete data from cache
    function remove(geturl) {
        sessionStorage.removeItem(_keyprefix + geturl);
        if (window.debug) {
            console.info("data removed from cache" + geturl);
        }
    }

    /**
     * check If This Route Affects Any Cached Data
     */
    Apicache.prototype.checkIfThisRouteAffectsAnyCachedData = function(url) {
        url = url.replace(uuid_regex, "{{id}}");
        var urlsthatwillaffectthisget_tocache = JSON.parse(sessionStorage.getItem('urlsthatwillaffectthisget'));
        if (urlsthatwillaffectthisget_tocache && urlsthatwillaffectthisget_tocache[url]) {
            urlsthatwillaffectthisget_tocache[url].forEach(function(geturl) {
                remove(geturl);
            });
        }
    }

    /**
     * Register the passed url for caching. Registering basically means 
     * I will make sure the API response for this URL is cached
     */
    Apicache.prototype.register = function(geturl, urlsthatwillaffectthisget) {
        var getroutes_tocache = JSON.parse(sessionStorage.getItem('getroutes_tocache'));
        if (!getroutes_tocache) {
            getroutes_tocache = []; //create a fresh variable
        }
        if (getroutes_tocache.indexOf(geturl) == -1) {
            getroutes_tocache.push(geturl);
        }
        var urlsthatwillaffectthisget_tocache = JSON.parse(sessionStorage.getItem('urlsthatwillaffectthisget'));
        if (!urlsthatwillaffectthisget_tocache) {
            urlsthatwillaffectthisget_tocache = {}; //create a fresh variable
        }
        if (urlsthatwillaffectthisget) {
            urlsthatwillaffectthisget.forEach(function(uwa) {
                var uwa = uwa.replace(uuid_regex, "{{id}}");
                if (!urlsthatwillaffectthisget_tocache[uwa]) {
                    urlsthatwillaffectthisget_tocache[uwa] = [];
                }
                urlsthatwillaffectthisget_tocache[uwa].push(geturl);
            });
            sessionStorage.setItem('urlsthatwillaffectthisget', JSON.stringify(urlsthatwillaffectthisget_tocache));
        }
        sessionStorage.setItem('getroutes_tocache', JSON.stringify(getroutes_tocache));
    }

    //A top level global instance where all the public ojects will be attached
    var apicache = new Apicache();

    // Attach the instance to window to make it globally available
    window.apicache = apicache;

})();