Avoid expired cache with the Microsoft Graph JavaScript SDK

Avoid expired cache with the Microsoft Graph JavaScript SDK

Here is a great pattern for the Microsoft Graph JavaScript SDK that will help you avoid your cached data expiring and improve your app's performance.

Cache for better performance

Caching data is a great way to improve your app's performance. Retrieving data from APIs is expensive and time-consuming. So if you can avoid requesting data unnecessarily, you can significantly increase your app's speed. After all, how often does a person's picture, name, or job title change?

Caching with the Microsoft Graph JavaScript SDK

Recently, I showed you how you can build a custom middleware for the Microsoft Graph JavaScript SDK to implement caching. The middleware did a great job of handling erroneous responses and allowing you to configure different cache durations for different routes. It only had one problem. When the cache expired, the user would need to wait for your app to load its data from the Microsoft Graph and refresh the cache.

Cache and refresh

Recently, Julie Turner told me about a pattern that she uses to extend the cache expiration period. Typically, the way you implement cache is that when as long as the cache is valid, you return the data from the cache and don't call the API. The upside is, that you don't call the API and your app is fast because the data is available instantly. The downside is, that at some point the data will expire and the user will need to wait for the fresh data.

The pattern that Julie told me about, means adjusting the cache logic, so that, when the cache is valid, the app will get the data from the cache and show it instantaneously. In parallel, the app will call the API to get the latest version of the data and refresh the cache. Because the cache is refreshed on each request, the data in the cache is kept fresh and almost always ready to be displayed to the user.

Here's how you'd implement this pattern in middleware for the Microsoft Graph JavaScript SDK.

Full sample with the cache middleware is available on GitHub.

export function BrowserCacheWithRefreshMiddleware(expirationConfig) {
  // ... trimmed for brevity

  return {
    execute: (context) => {
      return new Promise((resolve, reject) => {
        const requestKey = btoa(context.request);

        let response = window.sessionStorage.getItem(requestKey);
        let now = new Date();
        if (response) {
          const resp = JSON.parse(response);
          const expiresOn = resp.expiresOn ? new Date(resp.expiresOn) : undefined;
          if (!expiresOn || expiresOn > now) {
            console.debug(`From cache ${context.request}`);

            let body;
            if (resp.headers['content-type'].indexOf('application/json') > -1) {
              body = JSON.stringify(resp.body);
            }
            else {
              body = dataUrlToBlob(resp.body);
            }
            context.response = new Response(body, resp);
            resolve();
          }
          else {
            console.log(`Cache expired ${context.request}`);
          }
        }

        console.debug(context.response ? `Updating cache ${context.request}` : `From Graph ${context.request}`);
        this.nextMiddleware
          .execute(context)
          .then(async () => {
            if (context.options.method !== 'GET' ||
              context.response.status !== 200) {
              // don't cache non-GET or failed requests
              return resolve();
            }

            const resp = context.response.clone();

            const expiresOn = getExpiresOn(resp.url);
            // reset date to catch expiration set to 0
            now = new Date();
            // don't cache if the item already expired
            if (expiresOn <= now) {
              return resolve();
            }

            const headers = getHeaders(resp.headers);
            let body = '';
            if (headers['content-type'].indexOf('application/json') >= 0) {
              body = await resp.json();
            }
            else {
              body = await blobToDataUrl(await resp.blob());
            }

            response = {
              url: resp.url,
              status: resp.status,
              statusText: resp.statusText,
              headers,
              body,
              expiresOn
            };
            window.sessionStorage.setItem(requestKey, JSON.stringify(response));
            console.log(`Cached ${context.request}`);
            resolve();
          }, err => reject(err));
      });
    },
    setNext: next => {
      this.nextMiddleware = next;
    }
  }
};

Comparing to the original middleware, we start by returning a Promise rather than using the async pattern.

return {
  execute: (context) => {
    return new Promise((resolve, reject) => {
      // ... trimmed for brevity
    });
  }
}

We need to do this, in order to be able to return the data down the middleware chain as soon as possible while continuing the code execution to call Microsoft Graph and refresh the data in the cache.

Next, we use our existing logic and check if we have valid data in the cache. If we do, we assign it to the response. We resolve the Promise to let the middleware chain know that we're ready, but we don't exit the function just yet!

return {
  execute: (context) => {
    return new Promise((resolve, reject) => {
      // ... trimmed for brevity
      if (response) {
        // ... trimmed for brevity
        if (!expiresOn || expiresOn > now) {
          // ... trimmed for brevity
          context.response = new Response(body, resp);
          resolve();
        }
        // ... trimmed for brevity
      }

      // ... trimmed for brevity
    });
  }
}

Because our function keeps running, we call the next middleware in the chain, which will call Microsoft Graph to retrieve the fresh set of data:

return {
  execute: (context) => {
    return new Promise((resolve, reject) => {
      // ... trimmed for brevity
      console.debug(context.response ? `Updating cache ${context.request}` : `From Graph ${context.request}`);
      this.nextMiddleware
        .execute(context)
        .then(async () => {
          // ... trimmed for brevity
        }, err => reject(err));
    });
  }
}

When the middleware completes, we get back the response and cache it if possible. At each of the checkpoints, we call return resolve() to finish the execution and resolve the Promise.

That's it! With this approach, you'll serve data from the cache instantaneously and keep the cache fresh without users having to wait.

One more thing

Before using this pattern in your app though, consider how your app shows data. It could happen, that if your app shows the same data multiple times on the same page (for example information about people in the context of recent files, communication, and upcoming meetings), and the data has been updated between requests, the data on the page will be inconsistent. You could prevent this from happening by adding a minimum period for which the data will be kept in the cache.

Photo by SUNBEAM PHOTOGRAPHY on Unsplash

Comments

comments powered by Disqus