Angular SSR automatic cache refresh using Firestore 🔥

Angular SSR automatic cache refresh using Firestore 🔥
Photo by Alternate Skate / Unsplash

Howdy developers 👋

This is a follow-up post to finish what we have started in the last Angular SSR cache post.

To summarize we have used Redis as a cache to serve Angular SSR routes faster.

But we have a little gotcha here, when is this cache evicted/refreshed? and how we can properly do this.

In this article, I will try to answer these questions.

Why do we need to refresh the cache?

When an Angular route is cached, an HTML version of the page is stored in the cache.

This version of the page will be always served to the users until we evict the cache or refresh its content with a new version.

When to refresh the cache?

For the sake of this tutorial, Let's assume we have the following routes that contain a component that shows our products.

Routes containing the products-component :

  • /: Home page containing a set of products
  • shops/:owner: a shop containing a specific shop products
  • /details/:id: details page of a specific product

Caching strategy

We will define the following cache-refresh strategy:

When a product is added, updated or deleted we will :

  • update the homepage cached version
  • update the details page
  • update the product owner shop page

Now that we know why, when and what to refresh from our cache. Let's dig in to find out how we will listen to product changes and refresh the cache.

Triggering update events from our backend application

For brevity, we will assume that we have a backend application that uses a Firebase Firestore collection to notify us when a product is added/updated/deleted.

Our collection has one document with id: 1 and the following fields:

{  
    "product_id": 458,
    "owner": 784,
    "last_update": 1680605267610
}

Our backend application updates this object every time a product is created/updated/deleted.

Listen to change events

We will create a Nodejs script that will help us listen to product change events and update the cache accordingly.

We cannot add this code to our main application because when scaled the change events will be processed by all the instances of the application which will result in chaos somehow.

The cache updater script

// Configuration file
const config = require('./config.json');

// Redis 
const createClient = require('redis').createClient;

// Firebase
const initializeApp = require('firebase/app').initializeApp;
const collection = require("firebase/firestore").collection;
const getFirestore = require("firebase/firestore").getFirestore;
const onSnapshot = require("firebase/firestore").onSnapshot;
const query = require("firebase/firestore").query;

/**
 *
 * @returns Redis client instance
 */
function getCacheProvider() {

  console.log("Using redis cache")
  const cacheManager = createClient({
    socket: {
      reconnectStrategy() {
        console.log('Redis: reconnecting ', new Date().toJSON());
        return 5000;
      }
    },
    url: config.redisConnectionString, disableOfflineQueue: true
  })
  .on('ready', () => console.log('Redis: ready', new Date().toJSON()))
  .on('error', err => console.error('Redis: error', err, new Date().toJSON()));

  cacheManager.connect().then(() => {
    console.log('Redis Client Connected')
  }).catch(error => {
    console.error("Redis couldn't connect", error);
  })

  return cacheManager;
}

/**
 *
 * @returns The list of routes to be refreshed
 */
function buildCacheKeys(productChangeEvent) {
  // Always refresh the homepage
  const result = ["/"];

  // refresh shop details page
  if (productChangeEvent.owner) {
    result.push("/shop/" + productChangeEvent.owner);
  }
  // refresh product details page
  if (productChangeEvent.product_id) {
    result.push("/details/" + productChangeEvent.product_id);
  }
  return result;
}

/**
 *
 * @param cacheProvider Redis cache instance
 */
function listenToFirebaseUpdates(cacheProvider) {

  console.log("Listening to updates...");
  const cacheRefreshCollectionName = config.cacheRefreshCollectionName;

  const firebaseApp = initializeApp(config.firebase);
  const db = getFirestore(firebaseApp);
  const q = query(collection(db, cacheRefreshCollectionName));
  console.log("Fetching data from firebase: " + cacheRefreshCollectionName);

  onSnapshot(q, (snapshot) => {
    snapshot.docChanges().forEach((change) => {
      if (change.type === "modified") {
        const changeObject = change.doc.data();

        // Build the list of routes to be refreshed
        const cacheKeys = buildCacheKeys(changeObject);

        // For each route fetch teh page again to cache it
        cacheKeys.forEach(cacheKey => {
          console.log("Updating cache for route " + cacheKey);

          const url = config.appBaseUrl + cacheKey;
          console.log("Fetching data from URL: " + url)
          try {
            // Fetch the page using header Cache-control: no-cache
            // to avoid that the application returns a cached version
            fetch(url, {headers: {"cache-control": "no-cache"}})
            .then(response => {
              response.text().then(html => {

                console.log("data fetched, caching url: " + cacheKey)

                cacheProvider.set(cacheKey, html, 'EX', 300)
                .catch(err => 
                    console.log('Could not cache the request', err));
              })
            });
          } catch (error) {
            console.error("Error fetching data ", error);
          }
        })
      }
    });

  });
}

// Listen to firebase update and update the cache accordingly
listenToFirebaseUpdates(getCacheProvider());

Script configuraiton

For this script to work we need a configuration file config.json

  • appBaseUrl: The local address of the application on the server, we will use it to download the latest version of the page
  • redisConnectionString: The connection string of the Redis server
  • cacheRefreshCollectionName: The name of the collection containing the product update information on Firestore
  • firebase: The Firebase configuration object
{
  "appBaseUrl": "http://localhost:8080",
  "redisConnectionString": "redis://user:pass@localhost:6379",
  "cacheRefreshCollectionName": "cache-refresh-store",
  "firebase": {
    "apiKey": "dummy-api-key",
    "authDomain": "dummy-project-id.firebaseapp.com",
    "projectId": "dummy-project-id",
    "storageBucket": "dummy-project-id.appspot.com"
  }
}

Update the application to serve the non-cached version when requested

We have one last modification to do for this to work we need to update our Angular SSR application's server.ts to serve routes using Angular Universal instead of cache when the Cache-Control header is present.

 server.get('*',
    // Middleware to check if cached response exists
    (req, res, next) => {
      console.log("Checking for Cache-Control header");
      const cacheControlHeader = req.headers["cache-control"];
      if (cacheControlHeader === "no-cache") {
        console.log("Found header Cache-Control: no-cache.");
        next();
      } else {
        // serve from cache if page is cached 
        // otherwise serve using Angular Universal
      }
    }, 
    // Angular SSR rendering without cache
    // ...
  );

Now we are all set.

When a product is updated, the cache-updater app will regenerate the cache for the corresponding Angular route.

Source Code

https://github.com/camelcodes/redis-firebase-cache-refresh

Happy Coding !

Sponsorship