Goeleven.com

Cache Aside

C

Today I'd like to continue my journey on building offline progressive web apps.

In previous posts I've already covered how to create an offline app shell.

From here on the focus will be on designing web components in such a way that they can also go offline.

This will become a series of posts, starting off with basic concepts and gradually building from there into full fledged clients that will integrate seamlessly with the rest of the system by adhering to selected processing patterns.

The first of these concepts will be the cache aside pattern.

All state is cacheable

The cache aside pattern is a state transformation pattern, which relies on the natural characteristics of state:

  • State: Represents the data and structure of an entity at a given point in time.

As state is a snapshot of an entity at a given point in time, for that point in time state is immutable.

As long as you are aware that it is only valid for that specific point in time, it can be cached!

Just not forever

The older the state is, the bigger the risk of wrong decissions being taken by any task processor, which is either a user or a worker component, relying on it.

How big the risk is depends on the business capability that the state is used for.

If there are no task processors, state can be cached forever.

We still have team rosters from the 1980's on the club website, and that is perfectly fine.

However if a team coordinator would use this roster as a basis to compile next years roster, he would end up with a team full of old men instead of winning the next championship.

The team coordinator needs this years roster instead.

To mitigate any possible risk you can make the task processors aware of the point in time that the snapshot has been taken and provide them a way to replace the state when the risk gets to high.

Cache aside pattern

In the cache aside pattern, a capability maintains a cache of its data, next to requesting that data from its origin as well.

In context of PWA's, the origin is typically a REST api, owned by the capability itself or by a third party.

As cache, I prefer to use the browsers database, IndexedDB, because of the querying capabilities. IndexedDB is a key-value store, that also has the ability to define secondary indexes on properties of the stored values.

As an alternative you could introduce a caching layer at any state transformation point in the call chain instead, e.g.:

  • output caching on the API
  • request caching in a proxy server
  • response caching using google workbox

Cache Aside

Custom elements defined by the capability can now respond quickly by rendering themselves from data in the cache.

Only after being rendered they will refresh the cache and re-rendering themselves once more when there are changes.

Setting up indexeddb

The responsibility to set up tables and indexes in indexeddb is in the hands of the capability.

I typically initiate it from the micro app class, that already serves as the entry point in the app shell to register features, by opening a database instance and coordinate an upgrade if needed.


import dbFactory from "./messagehandler.eventsourcing.indexeddb.factory.js";
import config from "./roster.public/config.js";
import { RosterCache } from "./roster.public/cache.js";
import { RosterPublicFeature } from "./clubmanagement.roster.public.feature.js"

class RosterPublicApp{
    
  async initialize(){
    var _db = await dbFactory.open(config.dbname, config.dbversion, (db, tx) => {
            new RosterCache(db).upgrade(tx);
    });
    this.cache = new RosterCache(_db);

    shell.features.push(new RosterPublicFeature());
  }

}

The indexedDB api is slightly cumbersome, with several callbacks that need to be registered.

To make it easier to use, I've created a little factory class with an open method, which wraps all this complexity into a promise, and returns the open indexedDB instance when the promise resolves.

Except for the upgrade path, this remains a callback as it is only occasionally invoked, whenever the configured version number changes.

class IndexedDBFactory{
  open(name, version, onupgrade){
    return new Promise((resolve, reject) => {
        var request = indexedDB.open(name, version);
        request.onerror = function(event) {
            reject();
        };
        request.onsuccess = function(event) {
            var db = event.target.result;
            resolve(db);       
        };
        request.onupgradeneeded = function(event) { 
            var db = event.target.result;
            var tx = event.target.transaction;
            if(onupgrade) onupgrade(db, tx);
        };			
    });
  }
}
export default new IndexedDBFactory();

The uprade logic itself is capability specific and therefor delegated to the class which will also take care of the cache aside pattern.

During the upgrade, this class will check if the required object store and it's indexes exist. If not, they will be created.

import app from "../../clubmanagement.roster.public.app.js";

class RosterCache{
    
  constructor(db,){
    this.db = db;
  }

  upgrade(tx){    
    var rostersStore;
    if (!this.db.objectStoreNames.contains("rosters")) {
        rostersStore = this.db.createObjectStore("rosters", { keyPath: "rosterId" });
    }
    else{
        rostersStore = tx.objectStore("rosters");		
    }

    if(!rostersStore.indexNames.contains('organization')) {
        rostersStore.createIndex("organization", "organizationId");
    }

    if(!rostersStore.indexNames.contains('group')) {
        rostersStore.createIndex("group", "groupId");
    }
  }
}

export { RosterCache }

Once the database has been opened, a feature is registered, which will define the webcomponent in question.

Refer to my feature flags article to get more details about the feature activation steps in the capabilities lifecycle.

Render first, then refresh and re-render

Once the feature activation steps have taken place, the connectedCallback method on the web components will be called.

E.g. the CurrentRosterParticipants class will be invoked to render a list of people that are currently on the roster of a team.

Traditionally most web apps will

  • first load state from api
  • then render the loaded state

But as state is cacheable anyway, this logic can be inverted

  • first render cached state
  • then refresh cached state
  • finally rerender new state

Inverting the render load order will ensure faster response time, and allow the app to function when offline.

Going offline

By simply wrapping the refresh and rerender steps in a try/catch, and/or navigator.onLine check, you can now make your web component available offline.

The user has the old state rendered and can take action, assuming the risk of stale data is acceptable.

class CurrentRosterParticipants extends HTMLElement {

  get rosterId() {
    return this.getAttribute('data-roster-id');
  }

  set rosterId(val) {
    if (val) {
        this.setAttribute('data-roster-id', val);
    } else {
        this.removeAttribute('data-roster-id');
    }
  }

  async connectedCallback() {  

    await this.render();
    
    try
    {
        await this.refresh();
    
        await this.render(); // re-render
    }
    catch(){    
        if(!navigator.onLine){
            // handle offline
        }

        // handle error
    }
  }

  async refresh(){   
    await app.cache.loadRoster(this.rosterId); 
  }

  async render(){
    var roster = await app.cache.getRoster(this.rosterId);
    for(var participation of roster.participations){
        // actually render the roster participations
    }
  }

}

Render

To render any participations in the roster, the cached roster needs to be loaded from indexedDB.

When you know the object key, you can retrieve it using a the get method of the object store. This is illustrated in the getRoster method.

When you need to list objects by index, you can use openCursor on the index instead and provide it with a valid IDBKeyRange to perform some basic queries on the index. This approach is illustrated in the getRosters method.

Similar to the upgrade logic before, the get and cursor logic can also be wrapped in a Promise to make the indexedDB api a bit more consumeable.

class RosterCache{
    
  constructor(db,){
    this.db = db;
  }

  getRoster(rosterId){
    return new Promise((resolve, reject) => {
        var tx = this.db.transaction("rosters", "readonly");
        var store = tx.objectStore("rosters");
    
        var roster = null;
        store.get(rosterId).onsuccess = (e) => {
            roster = e.target.result;
        }
    
        tx.onerror = () => {
            reject();
        }

        tx.oncomplete = ()  => {
            resolve(roster);
        };
    });
  }

  async getRosters(groupId){
    return new Promise((resolve, reject) => {
        var tx = this.db.transaction("rosters", "readonly");
        var store = tx.objectStore("rosters")        
        var index = store.index("group");
    
        var range = IDBKeyRange.only(groupId);
        var rosters = [];
        index.openCursor(range).onsuccess = (e)  => {
            var cursor = e.target.result;
            if(cursor) {
                rosters.push(cursor.value);
                cursor.continue();
            }
        }
    
        tx.onerror = ()  => {
            reject();
        }

        tx.oncomplete = ()  => {
            resolve(rosters);
        };
    });
  }

}

Refresh

to refresh a cached roster, the code has to:

  • Call the REST api, which is exposing the latest roster state.
  • Transform the returned JSon to a roster object
  • Put the roster in indexedDB

Again I'm wrapping the indexedDB put invocation in a promise, so that it becomes easier to use.

class RosterCache{
    
  constructor(db,){
    this.db = db;
  }

  async loadRoster(rosterId){
    var uri = config.service + "/api/rosters/" + rosterId;
    var response = await fetch(uri, {
        method: "GET",
        mode: 'cors',
        headers: {
            "Content-Type": "application/json"
        }       
    });
    var roster = await response.json();
    await this.persistRoster(roster); 
  }

  persistRoster(roster){
    return new Promise((resolve, reject) => {
        var tx = this.db.transaction("rosters", "readwrite");
        var store = tx.objectStore("rosters");
    
        store.put(roster);

        tx.onerror = ()  => {
            reject();
        }

        tx.oncomplete = ()  => {
            resolve();
        };
    });
  }  
}

Re-render

If the refresh succeeds, re-rendering the results is in order.

As explained in a matter of state, while transitioning between different versions of state, you lack information of what happened in between.

The code will now need to figure out which records are new, which ui elements need to be removed and which ones have changed and need to be replaced or updated.

I typically do this by first marking all existing ui elements to be removed.

Then rerendering a new ui element from a template, for each record, and replace the existing one. If it didn't exist before I'll append the new element.

By replacing the existing element the code will basically wipe the to be removed marker and visualize any changes that might have happened even when not exactly knowing what changed.

And finally I will remove everything that is still marked as to be removed, those records no longer exist.

class CurrentRosterParticipants extends HTMLElement {

 async render(){
    var roster = await app.cache.getRoster(this.rosterId);

    // mark existing snippets
    for(var child of this.querySelectorAll("[data-target-id]")){
        child.classList.add("toRemove");
    } 

    for(var participation of roster.participations){
        
        // create a new snippet from a template
        // I'll cover the details of templating in a future post

        var participantTemplate = this.participantTemplate.cloneNode(true);
        for(var child of participantTemplate.children){
            child.setAttribute("data-target-id", participation.participationId);
        }

        // populate template

        // replace existing or append new
        var existing = this.querySelector(`[data-target-id="${participation.participationId}"]`);
        if(existing)
        {
            existing.replaceWith(participantTemplate);
        }
        else{
            this.append(participantTemplate); 
        }
    }

    var toRemove = this.querySelectorAll(".toRemove");
    toRemove.forEach(r => { r.remove(); });
  }

}

Wrapup

The cache aside pattern provides the basis to make progressive web apps offline capable.

The way it has been presented here is suitable for apps that are occasionally offline, e.g. when used on a cellphone connection.

For apps that are to be used offline for extended periods of time, you would not refresh on every request, instead you could do it when the app reconnects.

Likewise, the heavy reliance on state and the difficulties detecting changes between state instance make this pattern mainly suitable for readonly or read-heavy applications. For highly interactive applications, I'll use different patterns.

Back to guide

This article is part of the building offline progressive web apps guide. Return to this guide to explore more aspects of building offline progressive web apps.

About the author

YVES GOELEVEN

I love to build web and cloud apps and have been doing so for the past 20 years.

My main areas of expertise are progressive web applications, event driven architecture, domain driven design, event sourcing, messaging, and Microsoft Azure.

Through this blog I hope to share some of this experience with you.

Get in touch

Liked this content? Sign up to my newsletter and never miss any.

You can unsubscribe at any time by clicking the link in the footer of your emails. I use Mailchimp as my marketing platform. By clicking subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.