A New Spin on an Old Problem: Managing Token Refresh Cycles with Shared Web Workers

A quick word before we begin – this article assumes you have some basic working knowledge of JavaScript, Vue.js, Vuex, and token refresh cycles.

Popular oAuth authentication services will frequently serve back a JWT (JSON Web Token) as well as a refresh token (depending on your setup, of course) in order to grant users access to a web site or service. A JWT is sent back from your authentication server after a successful login and generally contains the following information encoded within:

  1. relevant user identification (such as an id or email address)
  2. user grants and claims
  3. expiration date of said token / grant

Depending on your authentication service that expiration time will vary, but ideally a user of your web application would like to keep their session alive without needing to manually reauthenticate. At the same time, your application and its api services have an obligation to prevent any unauthorized access or utilization. To do this, many authentication services will provide a refresh token which can be sent back to the service and exchanged for new JWT credentials without needing to maintain or otherwise resupply a username and password.

Token refresh cycles are generally managed client side, in application state, and set to renew on timed intervals, usually via JavaScript. This is all well and good until you have multiple tabs with the same application running at the same time and need to start managing multiple refresh requests. This may very well try to request new tokens at the same time with the same payload information, which leads to an increase of noisy 401 / 403 server errors which will clutter up any error reporting you have in place.

There are multiple solutions for this problem:

  • a custom token refresh locking mechanism
  • “free-for-all” requests with graceful failing of unauthorized calls
  • utilizing background threading (web workers)

The purpose of this article is to persuade you to give web workers a try for this specified use case.

What is a web worker?

In simple terms, a web worker is any browser-provided service which allows scripting to be run in a background thread (client side). Nearly every major modern web browser fully supports web workers, although for unknown reasons the adoption of these workers has been slow. JavaScript, which is traditionally single-threaded, can stand to make many uses of a background thread process via web workers, and I argue that token refresh cycles are a perfect utilization.

Before we dive into implementation, there are a few caveats to keep in mind when working with web workers:

  1. For a web worker to collaborate with a web application, they must all be within the same domain (CORS same-origin policy).
  2. Web workers are unable to access DOM elements or the window object.
  3. Shared web workers (covered below) are not supported by many mobile web browsers. Arguably, this is not much of a detriment to adoption because most mobile application usage does not rely on multiple browser tabs.

There are two kinds of web workers:

  1. Web worker: can only be accessed by the script which created it
  2. Shared web worker: can be accessed by any script in any browser tab (from the same domain)

How this solves our problem

As you may have guessed, shared web workers serve as the perfect vehicle to manage token refresh cycles for Single Page Applications (SPAs) since they can “listen” to multiple open tabs at once. In fact, shared web workers are accessible to not only the browser application, but also other applications and even other web workers themselves.

The mechanism by which shared web workers communicate with these objects is via the browser MessagePort object. MessagePorts are an interface for the Channel Messaging API and come equipped with some basic functionality for handling messages, such as connecting, sending messages, handling errors, and closing connections. You’ll see this in action in the examples to follow.

Depending on the type of web worker you are using, there are different ways to inspect any active web workers you have in your working session. For standard web workers, you may see the worker listed underneath the “sources” tab of your browser’s web developer tools. For shared workers, the method varies between browsers. In Chrome, you can view any running web workers by opening up a new tab and navigating to chrome://inspect/#workers in the address bar. A new browser window will appear in which you will be able to observe any active workers. Your web worker will also have a separate console available for logging, which is essential for keeping track of timers across multiple tabs, as we will soon cover.

Implementation

Our Vue.js (Vue2) application for this implementation uses Webpack to configure and build our application. As such, we’ve added a new workers folder to our public directory, which is then copied into our final dist folder upon building for release. Since web worker files are running in separate threads to our main application, these must be in separate files from our main application, and therefore are outside of the standard src folder.

Components:

  1. Our worker initialization files: These are the files which call upon the browser’s MessagePort object.
    Web Worker files in public folder
  2. Within our Vue application itself, we have two helper files to communicate with our web workers:
    Web Worker helps in our Vue.js application
  3. And lastely, in App.js, we have the specific code which initializes and manages the web workers.

Our external share web worker file (in our public folder, named sharedWorker.js in this example), looks like this:

let timers = [];

const alreadyHaveTimerType = (dataType, obj) => {
  return obj.name === dataType;
};

onconnect = (ev) => {  
  const [port] = ev.ports; 
  
  port.onmessage = (MessageEvent) => {
    const { eventName, data = {} } = MessageEvent.data;
    
    if (eventName === 'setInterval') {
      const dataTypeObjTimersExist = timers.some(alreadyHaveTimerType.bind(null, data.type));

      if (dataTypeObjTimersExist) {
        
        const prunedTimers = timers.filter(timerObj => {
          if (timerObj.name === data.type) {
            clearInterval(timerObj.timer);
          }
          return timerObj.name !== data.type;
        });
        
        timers = prunedTimers;
      }

      const intervalObj = setInterval(() => {
        port.postMessage({
          event: eventName,
          data,
        });
      }, data.interval);
      
      const newTimerObj = {
        name: data.type,
        timer: intervalObj,
        appUuid: data.appUuid,
      };
      
      timers.push(newTimerObj);
    }
    if (eventName === 'clearAllTimers') {
      
      timers.forEach((t) => {
        clearInterval(t.timer);
      });
      
      port.postMessage({
        event: eventName,
      });
      
      timers = [];
    }
  };
};

Let’s take a moment to break this down: The first thing we do is initialize an array of timers:

let timers = [];

This step is not always necessary, but it makes the code more reusable in that you can can simply add or remove as many different timers as you like with the same script. For example, you can use one timer for managing token refresh cycles, and another for polling on changes for some other data set, all with the same shared web worker.

Next, we include a method (alreadyHaveTimerType) which will be used to make sure we don’t end up having concurrent timers performing the same function.

The next bit of code is mostly standard across the web as it will be connecting to (and listening for messages on) the browser’s MessagePort object.

onconnect = (ev) => {  
  const [port] = ev.ports; 
  
  port.onmessage = (MessageEvent) => {
    const { eventName, data = {} } = MessageEvent.data;

Lastly, we have two other important logic checks going on in this file. The first happens when the MessagePort detects a setInterval event, and then we check whether or not a timer of that type already exists, as we only ever one one timer of each time for this application. If there is already a timer of the same type, we remove it and add our new timer (with the current app uuid, to be explained in a moment). The second happens when we send a clearAllTimers event, in which case we destroy all existing timers.

Now let’s take a moment to look at the code in sharedWorkerHelper.js:

export default class SharedWorkerHelper {
  constructor() {
    // Check for browser Shared Web Worker support before initialization
    if (window.SharedWorker) {
      this.worker = new SharedWorker('/workers/sharedWorker.js');
      this.events = {};
      this.worker.port.start();

      this.worker.port.addEventListener(
        'message',
        (e) => this.callEventCallback(e.data),
        false,
      );

      this.worker.port.addEventListener(
        'error',
        () => this.callEventCallback('error'),
        false,
      );
    }
  }

  trigger(eventName, data) {
    // Trigger an event message towards the worker script
    this.worker.port.postMessage({
      eventName,
      data,
    });
  }

  clearAllTimers() {
    // Clears all timers
    this.worker.port.postMessage({
      eventName: 'clearAllTimers',
    });
  }

  terminate() {
    // Terminates the worker thread with its events and timers
    this.clearAllTimers();
    this.worker.port.postMessage({
      type: 'cmd',
      action: 'die',
    });
  }

  on(eventName, callback) {
    // Record events and callbacks to listen on
    this.events[eventName] = callback;
  }

  callEventCallback({ event, data }) {
    // Call external callback based on event listened
    if (Object.prototype.hasOwnProperty.call(this.events, event)) {
      this.events[event].call(null, data);
    }
  }
}

This is all pretty standard stuff as far as shared web workers go, so let’s jump ahead to App.vue and take a look at how we are actually calling these methods.

The first thing we’ll do in App.vue is import our helper file:

import SharedWorkerHelper from '@/workers/sharedWorkerHelper';

Next, in our data() method, we’ll define three pieces of state:

  1. a uuid for this application instance (initialized to null)
  2. a worker object (also initialized to null)
  3. a refresh interval (in miliseconds) for how long we want each token refresh cycle to be
    App.vue data state initialization

We’re also going to add a few computed properties to verify whether or not our Vue state has an active shared web worker and whether or not different types of web workers are supported:

haveActiveWorker() {
  return !!this.worker;
},
supportsSharedWorkers() {
  return !!window.SharedWorker;
},
supportsWebWorkers() {
  return !!window.Worker;
},

In the created() Vue lifecycle hook, we are going to do a few things. Firstly, we are going to use the npm uuid package to generate a unique identifier for this instance of our application.

this.uuid = uuidv4();

Secondly, we will to try to close out any previously existing web workers for this application, to prevent duplicates. After this, we’ll attempt to create a new worker.

try {
  this.handleWorkerEnd();
} catch (e) {
  // do nothing, we have no straggling workers
}
if (!this.worker) {
  if (this.supportsSharedWorkers) {
    this.worker = new SharedWorkerHelper(this.uuid);
  } else if (this.supportsWebWorkers) {
    this.worker = new WorkerHelper(this.uuid);
  } else {
    console.log('web workers not supported in this browser');
  }
}

Next, we’ll make sure that we have a refresh interval defined and that we are not on a page which does not require authentication (such as the login page), before actually firing up our shared web worker.

if (this.currentRefreshInterval > 0 && !this.isAuthenticationView) {
  this.setupWorkerActions();
  this.handleWorkerStart();
}

Still in App.vue, we are now going to define a few methods to manage our worker’s lifecycle:

async handleWorkerStart() {
  await this.createWorker();
  await this.setupWorkerActions();
  this.worker.on('setInterval', () => {
    this.handleWorkerUpdate('refreshTokens');
  });
},
handleWorkerUpdate(type) {
  switch (type) {
    case 'refreshTokens':
      this.refreshTokens(this.currentRefreshToken);
      if (this.authErrors.length > 0) {
        this.handleWorkerEnd();
        this.$router.push('/loggingyouout');
      }
      break;
    default:
      break;
  }
},
handleWorkerEnd() {
  if (this.worker) {
    this.worker.terminate();
    this.worker = null;
  }
},
setupWorkerActions() {
  this.worker.trigger('setInterval', {
    interval: this.currentRefreshInterval,
    type: 'refreshTokens',
    appUuid: this.uuid,
  });
},

Note: this.authErrors and this.currentRefreshTokens are both Vuex getters in our authorization module, as is the action this.refreshTokens.

In App.vue, handleWorkerUpdate is being used to format and define types of updates. In the example code we are only covering the refreshTokens update event, but this can be enhanced to field any number of timer types to best suit the needs of your application. Within the refreshTokens case of the handleWorkerUpdate method is where we actually call our token refresh api endpoint.

Using the web worker to manage the timer enables us to keep the logic (and state data) for token refresh within the application itself, avoiding unnecessary data exposure of the tokens themselves. Additionally, the uuid is included in the worker payload for this worker method in order to assign itself as the primary app instance which is “managing the timer.” The ultimate effect is each tab is calling upon the same type of timer for the token refresh cycle. Each tab will send up its own unique identitifier (uuid) in doing so, meaning that each subsequent request (from any tab) will overwrite any existing tab: only one tab will ever be called upon by the timer to execute a token refresh. This prevents multiple tabs from trying to request new JWTs simultaneously.

For example, let’s suppose we have our application open in two browser tabs. The “active” tab is the one which has initialized the web worker and sent over its uuid. The web worker is managing the timer for the refreshToken method within the App, but the app itself is pulling the token information from local storage as well as calling the token refresh api endpoint.

Step 1

Then let’s say our application user switches tabs. Our code runs in that instance with a different uuid. The shared web worker recognizes that there is already a token refresh timer with a different uuid: it removes the existing timer and creates a new one based on the current active tab. Step 1

The current active tab then takes over making the calls to the token refresh endpoint as well as managing the JWT and refresh tokens within local storage. Step 1

This process is organically repeated every time a new tab is opened for this application (within the same domain).

Note: In our examples, we also include the code to manage a regular web worker as a fallback in case shared web workers are not supported. For those cases, our helper file looks like this:

export default class WorkerHelper {
  /*
  * Instantiate the Web Worker
  * Trigger an event message externally towards the worker script
  * Receive the message from the worker script and handle the event call
  * External listeners listen on that event and trigger their own callback
  */
  constructor() {
    if (window.Worker) {
      this.worker = new Worker('/workers/worker.js');
      this.events = {};

      this.worker.addEventListener(
        'message',
        (e) => this.callEventCallback(e.data),
        false,
      );

      this.worker.addEventListener(
        'error',
        () => this.callEventCallback('error'),
        false,
      );
    }
  }

  trigger(eventName, data) {
    // Trigger an event message towards the worker script
    this.worker.postMessage({
      eventName,
      data,
    });
  }

  clearAllTimers() {
    // Clears all timers
    this.worker.postMessage({
      eventName: 'clearAllTimers',
    });
  }

  terminate() {
    // Terminates the worker thread with its events and timers
    this.clearAllTimers();
    this.worker.terminate();
  }

  on(eventName, callback) {
    // Record events and callbacks to listen on
    this.events[eventName] = callback;
  }

  callEventCallback({ event, data }) {
    // Call external callback based on event listened
    if (Object.prototype.hasOwnProperty.call(this.events, event)) {
      this.events[event].call(null, data);
    }
  }
}

and our worker file looks like this:

const timers = [];

const alreadyHaveTimerType = (dataType, obj) => {
  return obj.name === dataType;
};

onmessage = (MessageEvent) => {
  const { eventName, data = {} } = MessageEvent.data;
  
  if (eventName === 'setInterval') {
    const dataTypeObjTimersExist = timers.some(alreadyHaveTimerType.bind(null, data.type));

    if (dataTypeObjTimersExist) {
      const prunedTimers = timers.filter(timerObj => {
        if (timerObj.name === data.type) {
          clearInterval(timerObj.timer);
        }
        return timerObj.name !== data.type;
      });
      
      timers = prunedTimers;
    }

    const intervalObj = setInterval(() => {
      postMessage({
        event: eventName,
        data,
      });
    }, data.interval);
    
    const newTimerObj = {
      name: data.type,
      timer: intervalObj,
      appUuid: data.appUuid,
    };
    
    timers.push(newTimerObj);
  }
  if (eventName === 'clearAllTimers') {
    timers.forEach((t) => {
      clearInterval(t.timer);
    });
    
    postMessage({
      event: eventName,
    });
    
    timers = [];
  }
};

What do you think of this solution?

How have you handled the challenge of multiple browser tabs for token refresh cycles and polling? Do you see yourself or your organization leveraging shared web workers in the future? We would love to hear what you think.