Sean van Mulligen Full Stack Developer at WineDirect

Cache invalidation strategies using IndexedDB in Angular 2+

9 min read 2798

Cache invalidation strategies using IndexedDB in Angular 2+

Creating offline-first web applications is not a trivial task. There are numerous considerations to be made (more than can be covered in a single article) and there are many potential pitfalls. However, there are two very important considerations to be made with respect to storing data in the user’s browser: where to store the data, and how to ensure that it is not stale.

Imagine you are tasked with building a point-of-sale (POS) application that needs to be able to function in areas with poor network connectivity. Caching product information, customers, and purchase data requires significant planning. How do you architect your app so that it has a low network footprint? Where do you store the data to ensure that it will be accessible if there is no network access? How do you ensure that all of your devices running the application are using the same set of data?

Storage options

Caching data client-side isn’t a new concept in the web development world. HTML Web Storage has been supported by all major browsers since 2009, and some front-end frameworks have built-in caching mechanisms.

The RxJS library has two operators that can be used for caching HTTP calls: publishReplay() and refCount(). Using these two operators to cache the last emitted value from an Observable is a cheap and quick way to implement caching in Angular, especially if your data doesn’t change often. If your data changes frequently or if your app needs to be fully offline capable, you will want to look towards browser storage.

The localStorage API is an alluring candidate due to its ease of use and maturity. However, it has its drawbacks. In most browsers, you will be limited to storing ~5MB of data. For many scenarios, this is enough, but if you plan on developing an offline-first application you could quickly find yourself reaching that limit. It is also synchronous, which means if you are constantly accessing the localStorage API, the rest of the JS on the page will need to wait for the action to complete. Developing an offline-first progressive web application using localStorage as the primary client-side data storage mechanism is an uphill battle.

Thankfully, we have IndexedDB. IndexedDB is an asynchronous, client-side, NoSQL storage that is currently supported by over 90% of user’s browsers. In Chrome, Firefox, and Edge, IndexedDB’s storage quota is determined based on free disk space, and in some cases is tiered based on the volume size as well. In all modern browsers, the storage limit is at least 50MB, so it is safe to assume you will be able to store more than the 5MB afforded by localStorage. In many cases (especially on desktop devices), storing gigabytes of data is possible.

As with any technology, there are some drawbacks to IndexedDB. The API is quite clunky, and not nearly as straightforward as localStorage.

Below is a very basic example of what it takes to create a transaction and insert an item using the IndexedDB API.

function insertIntoDB() {
    // Create a product to be inserted into the database
    var products = [
      { id: 1, name: 'T-Shirt', price: 10 }
    ];

    // Create a transaction for inserting into the DB
    var transaction = db.transaction(["products"], "readwrite");
    var objectStore = transaction.objectStore("products");
    
    // Finally add the item. This returns an IDBRequest object
    var objectStoreRequest = objectStore.add(products[0]);
    
    objectStoreRequest.onsuccess = function(event) {
      // Success callback
    };

    objectStoreRequest.onerror = function(event) {
      // Error callback
    };
};

This example assumes that you have already created the database, and defined the schema, which can be a complicated process itself.

Unfortunately, the work required to handle the default IndexedDB API can be off-putting to many developers, and cause them to sacrifice scalability and performance for localStorage’s alluring ease of use. I don’t blame them, unless you want to be stuck in callback hell, I would suggest avoiding the default IndexedDB API.

If you are using Angular2+ there is a good chance that you will want to use RxJS Observables to manage your asynchronous tasks (like IndexedDB storage and retrieval). Thankfully, there are some great Angular2+ libraries that improve upon IndexedDB, giving it an API similar to localStorage, but with the benefits of asynchronous client-side storage. In particular, @ngx-pwa/local-storage is a great library for Angular apps. The API is very straightforward, and it has built-in support for RxJS Observables.

Here is an example of what saving an item to IndexedDB with @ngx-pwa/local-storage might look like:

// Notice how the API looks just like localStorage. Simple, and familiar.
this.localStorage.setItem('products', products).subscribe(() => {});

// ...and it can be simplified even further with auto-subscription:
this.localStorage.setItemSubscribe('products', products);

The interface is much cleaner, and you don’t need to use callbacks, set up the transaction, or manage the database.

Keeping your data fresh (cache invalidation)

Assuming you have decided to use IndexedDB, the next critical decision will be how to ensure that your cached data stays up to date. You could just make HTTP requests on demand whenever the user performs an action, but that detracts from the overall user experience. You will end up using more bandwidth, and it is impossible to guarantee a consistent user experience when you are at the mercy of the user’s network.

Caching invalidation is by no means an easy challenge to tackle. There is a reason for the famous (albeit tongue-in-cheek) quote by Phil Karlton:

“There are only two hard problems in Computer Science: cache invalidation, and naming things.”

Each strategy proposed below will have drawbacks and tradeoffs. If your users need to have the newest and most up-to-date set of data whenever your app is online, expect to pay a price.

The type of data you are storing, and the specific circumstances surrounding the lifecycle of that data will also impact which strategy you choose. For the sake of this article, we will use our theoretical scenario of a point-of-sale (POS) web app that caches sellable products in the user’s browser.

Polling

One of the simplest cache invalidation strategies, polling, can be implemented in multiple ways. There are two main types of polling – short polling, and long polling. In my opinion, server-sent events or WebSockets are almost always a better alternative to long-polling techniques. For this article, we will only cover short polling. However, if you are interested in learning more about long-polling techniques, I would suggest starting with Comet and the strategies that it encompasses.

A simple example of short-polling to retrieve a listing of products to display and cache on the POS app might look something like this:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, interval } from 'rxjs';
import { map, tap, concatMap } from 'rxjs/operators';
import { LocalStorage } from '@ngx-pwa/local-storage';

interface Product { 
  id: number, 
  name: string, 
  price: number 
} 

@Injectable({
  providedIn: 'root'
})
export class PollingService {
  private api = 'http://localhost:3000';
  
  constructor(
    private http: HttpClient,
    private localStorage: LocalStorage
  ) {}
  
  public poll(): void {
    interval(10000) // Poll every 10s
      .pipe(
        // concatMap ensures that each request completes before the next one begins.
        // Use of concatMap becomes increasingly important as your polling frequency goes up.
        concatMap(() => this.updateCache())
      ).subscribe();
  }
  
  private updateCache(): Observable<Product[]> {
    return this.localStorage.getItem<any>('products').pipe(
      concatMap(() => {
        return this.getProducts().pipe(
          tap(products => {
            if (products.length) {
              // This will set a Product[] in IndexedDB with a key of 'products'
              this.localStorage.setItemSubscribe('products', products);
            }
          }),
          map(products => {
            return products;
          })
        );
      })
    );
  }
  
  private getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(`${this.api}/products`)
      .pipe(
        map(products => {
          return products;
        }),
      );
  }
}

The service contains three functions: poll(), updateCache(), and getProducts(). The poll() function invokes updateCache() every ten seconds, which then triggers an HTTP request to the server. If data is returned, it is then stored in IndexedDB.

Short-polling has a few drawbacks. Any HTTP request made whenever there isn’t newer data to be retrieved is wasted bandwidth. In a real-life scenario, polling a “list” endpoint every ten seconds will also put unnecessary strain on your database. There are ways to help make polling more efficient, but they involve back-end infrastructure changes. I won’t dive too deep, but with the use of updatedAt timestamps, you can potentially reduce the load on your database when polling.

Imagine our /products endpoint returns us objects like this:

[
  { "id": 1, "name": "T-Shirt", "price": 10, "updatedAt": "2019-04-23T18:25:43.511Z"},
  { "id": 2, "name": "Mug", "price": 5, "updatedAt": "2018-04-23T19:22:13.601Z"}
]

We could use the updatedAt property as a way to tell the server what version of the data we have. Inside the getProducts() function in the PollingService example above, we could pass through the most recent updatedAt timestamp that we have available in our IndexedDB, as a URL parameter in the HTTP request.

On the backend, you could simply run a query to find out when the last product was added or modified:

SELECT TOP 1 updatedAt
FROM products
ORDER BY updatedAt DESC

If the timestamp from the client is different than the one from the query, then obviously products have been added, deleted, or otherwise modified, and we should run a query to grab all of the products. If the timestamp matches the one from the query, then we could simply return a special response to the client specifying that the cache is up to date.

This timestamp strategy can also be used in conjunction with server-sent events and WebSockets to ensure that data remains fresh after network connectivity loss.

However, we are making one large assumption with the timestamp strategy. We are assuming that the time it takes to check the timestamp is significantly less than the time required to list all products from the backend database. Depending on your database, indexes, and overall back-end infrastructure the differences may be negligible.

Server-sent events

Often overlooked and overshadowed by WebSockets, the server-sent event (SSE) API is an HTML specification for sending data unidirectionally from the server to a client. The biggest appeal of SSE over WebSockets is its ease of use. Unlike WebSockets, a SSE is transported over the HTTP protocol with a special MIME type of text/event-stream. As a result, it is very easy to use your existing server infrastructure to implement SSE. Unfortunately, SSE are not supported in any version of Internet Explorer or Edge. However, there are polyfills available.

Handling SSE from within Angular is straightforward. Each event has a type and data. For our example, we want to be notified whenever a product is added, updated, or deleted. Our assumption is that the backend sends a different type of event for each. Therefore, we attach a listener to each type of event:

import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';

interface Product { 
  id: number, 
  name: string, 
  price: number 
} 

@Injectable({
  providedIn: 'root'
})
export class ServerSentEventService {
  private api = 'http://localhost:3000/stream';
  private eventSource: EventSource;
  
  constructor(
    private localStorage: LocalStorage
  ) {
    this.eventSource = new EventSource(this.api);
  }
  
  public listen(): void {
    this.eventSource.addEventListener('productAdd', (message: MessageEvent) => this.addProductToCache(message));
    this.eventSource.addEventListener('productUpdate', (message: MessageEvent) => this.updateProductInCache(message));
    this.eventSource.addEventListener('productDelete', (message: MessageEvent) => this.deleteProductFromCache(message));
  }
  
  ...
}

We have the listeners set up, so now all we need to do is update our cache. When an event is triggered, depending on the type of the event, one of the callbacks functions for the listeners will be triggered. When a productAdd event is received, we simply want to append the product object from the MessageEvent to our current IndexedDB cache. Similarly, for productUpdate and productDelete, we want to retrieve items from our IndexedDB database and make the necessary adjustments.

Below is a working example of the implementation needed to add, update, or delete items from the IndexedDB when SSE are received.

import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';

interface Product { 
  id: number, 
  name: string, 
  price: number 
} 

@Injectable({
  providedIn: 'root'
})
export class ServerSentEventService {
  private api = 'http://localhost:3000/stream';
  private eventSource: EventSource;
  
  constructor(
    private localStorage: LocalStorage
  ) {
    this.eventSource = new EventSource(this.api);
  }
  
  public listen(): void {
    this.eventSource.addEventListener('productAdd', (message: MessageEvent) => this.addProductToCache(message));
    this.eventSource.addEventListener('productUpdate', (message: MessageEvent) => this.updateProductInCache(message));
    this.eventSource.addEventListener('productDelete', (message: MessageEvent) => this.deleteProductFromCache(message));
  }
  
  private addProductToCache(eventMessage: MessageEvent): void {
    this.localStorage.getItem<Product[]>('products').subscribe((products: Product[]) => {
      products = products || []; 
      let eventMessageProduct: Product = JSON.parse(eventMessage.data);
      products.push(eventMessageProduct);

      this.localStorage.setItemSubscribe('products', products);
    });
  }
  
  private updateProductInCache(eventMessage: MessageEvent): void {
    this.localStorage.getItem<Product[]>('products').subscribe((products: Product[]) => {
      let eventMessageProduct: Product = JSON.parse(eventMessage.data);
      let productIndex = products.findIndex((product => product.id === eventMessageProduct.id));
      products[productIndex] = eventMessageProduct;

      this.localStorage.setItemSubscribe('products', products);
    });
  }
  
  private deleteProductFromCache(eventMessage: MessageEvent): void {
    this.localStorage.getItem<Product[]>('products').subscribe((products: Product[]) => {
      let eventMessageProduct: Product = JSON.parse(eventMessage.data);
      products = products.filter(item => item.id !== eventMessageProduct.id)

      this.localStorage.setItemSubscribe('products', products);
    });
  }
}

As you can see, in all scenarios we load up the existing products from the IndexedDB database and make an adjustment to the objects within the array. These operations are followed by a setItem in each case.

There is one glaring flaw with this strategy, however. If the client’s internet connection is lost, or the connection to the server is terminated, how do we know that the data will not be outdated when the connection is regained?

Reconnecting to the event stream is trivial (and happens automatically in some browsers) but there is always the chance that some events were missed. This is a caveat with both SSE and WebSockets, and necessitates the use of a cache refresh (e.g. using a timestamp as described in the polling section) whenever the application regains network connectivity. To detect network connectivity status you could use the navigator.onLine property.

In addition to refreshing the cache whenever internet connectivity is lost, you will want to force a refresh whenever a user initiates a new session on your app (e.g. user logs in).

WebSockets

Similarly to server-sent events, WebSockets (WS) allow for data to be transferred to the client from the server in real-time. However, the WebSocket protocol allows for bidirectional data transfer – that is to say, data can be transferred from the client to the server over the same connection. WebSockets are supported in IE and MS Edge, which makes it a great alternative to SSE.

However, there is a catch. WebSockets uses its own protocol instead of HTTP, so to utilize WebSockets you will need to have a server set up that can handle the WebSocket protocol.

Apart from that, it is fairly straightforward to set up a WebSocket connection that listens for incoming messages.

import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from "rxjs/webSocket";
import { LocalStorage } from '@ngx-pwa/local-storage';

interface Product { 
  id: number, 
  name: string, 
  price: number 
} 

interface Message {
  action: string,
  data: Product
}

@Injectable({
  providedIn: 'root'
})
export class WebSocketService {
  private api = 'ws://localhost:3000/echo';
  private subject: WebSocketSubject<string>;

  constructor(
    private localStorage: LocalStorage
  ) {}
  
  public connect(): void {
    this.subject = webSocket(this.api);
    this.subject.subscribe(
      message => this.dispatchMessage(message)
    );
  }
  
  private dispatchMessage(message: string): void {
    let parsedMessage: Message = JSON.parse(message);
    switch(parsedMessage.action) {
      case 'add':
        this.addProductToCache(parsedMessage);
      case 'update':
        this.updateProductInCache(parsedMessage);
      case 'delete':
        this.deleteProductFromCache(parsedMessage);
    }
  }
  ...
}

Unlike SSE, the messages received via WebSockets do not have a type property. WebSocket messages are just a string value, with no additional metadata. Because there are multiple possible actions, we need to expand the JSON object being sent from the server to also include the desired action (e.g. add, update, or delete). An alternative solution to this would be to open a connection to a different endpoint for each type of action.

The Message type that we defined via an interface at the top of the example has this additional action parameter. The dispatchMessage() function can then use that to determine which action to perform on the cache with the data. At this point, the logic for each of the add, update, and delete functions remains functionally the same as in the SSE example.

Similarly to our SSE example, WebSockets need to be used in tandem with an initial fetch of information whenever the application comes online, whether it be on initial login, or after a network outage.

Conclusion

In this post we’ve explored where to store data client-side for progressive web applications, as well as cache invalidation strategies for keeping that data fresh.

IndexedDB is a great solution for client-side storage, and it can be enhanced through the use of third-party libraries that provide a cleaner API for storage and retrieval.

There are multiple tools that can be used for updating the client-side cache, but a combination of (infrequent) polling along with a technology that can push data from the server to the client in real-time (server-sent events or WebSockets) ensures that the cache is updated as soon as possible, while minimizing unnecessary network overhead.

Plug: , a DVR for web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Sean van Mulligen Full Stack Developer at WineDirect

3 Replies to “Cache invalidation strategies using IndexedDB in Angular 2+”

  1. Very well written and helpful. Thanks for taking the time out of your day to help explain this.

  2. Hi! Thank you for helpful example. Now I faced with the same task. But also I need to be able save new order to cache, in case offline, and then sync they when connection will restored. Do you have solution for this case? Thank you in advance!

Leave a Reply