Can you please refresh (Or how we version our Single-Page Application)

originalexe

Ante Sepic

Posted on December 3, 2019

Can you please refresh (Or how we version our Single-Page Application)

In this article, I outline our approach to solving the problem of people not "getting" the latest version of our SPA.

A single-page application (SPA) is a web application or web site that interacts with the user by dynamically rewriting the current page rather than loading entire new pages from a server.

At work, I am responsible for the development of our client-facing SPA. We use Vue.js as our frontend framework of choice, but the problem I will describe in this article is framework agnostic.

Upon making changes and merging them to the master branch on GitHub, Travis (not a real person) runs our deployment process which includes building the app via Vue CLI and then uploading the new build to Amazon S3. So far so good, right?

What could go wrong

The fundamental advantage of SPAs (people not having to load the whole HTML on route change) is also what was creating a problem. If people could technically never re-request the app fresh from your server, how do you deliver the latest version to them?

New version detection

The way we implemented a new version detection is pretty simple: We periodically do a fetch of the main index.html file and compare it to the old version. If there is a difference, it means that a new version got deployed. The good thing is that we don't have to do any versioning manually. index.html is guaranteed to be different on each build because Webpack generates a unique hash for each file during the build process, and hash is part of the file name embedded into the HTML. Since we only care whether the version is different or not (there is no concept of higher/lower version), this is enough.

Letting them know

We knew that we somehow want to let the people know that there was a newer version available. As for how we "deliver the message", there were three versions that came to our mind:

1.) Automatically refresh
This was discarded immediately because it could interrupt and/or confuse users. Imagine that you are filling out some form and a website refreshes for no reason, losing your progress.

2.) Intercept route change and reload from the server
Users would not be disturbed by this one since they are navigating to another page anyway. The only reason we did not go for this one is that it would break some flows where we rely on information being preserved in the Vuex store in between route navigations.

3.) Showing notification
In the end, we decided to go for showing an in-app notification, with a link that would trigger a refresh. That way our users can finish what they were doing and update the app when they are ready.

Implementation details

To periodically check for a new version, we decided to use AJAX polling since it will require no additional tooling, and other technologies like web sockets would be an overkill. The interval for the check is 10 minutes. A naive approach would be using a simple setInterval and firing a network request every 10 minutes. The drawback of that approach is that network requests are not free. Not in terms of bandwidth (HTML file is really small), but in terms of battery. You can read more about it here. The gist is: if a person is not using the network for some time on their device, the in-device modem will go into the low-power mode. Getting it back to the "normal" state takes some energy. If we just fire network requests every 10 minutes, we run the risk of draining our users' battery more than we need to.

Solution: Activity Based Timer

Here is the full code:

const ActivityBasedTimer = () => {
  let globalTimerId = 0;
  const timers = new Map();

  const maybeExecuteTimerCallback = ({ timerId, forced = false }) => {
    const timer = timers.get(timerId);

    if (timer === undefined) {
      return;
    }

    const {
      callback,
      interval,
      forcedInterval,
      forcedIntervalId,
      lastExecution,
    } = timer;
    const intervalToCheckFor = forced === true
      ? forcedInterval
      : interval;
    const now = Date.now();

    if (now - lastExecution < intervalToCheckFor) {
      return;
    }

    const newTimer = {
      ...timer,
      lastExecution: now,
    };

    if (forcedIntervalId !== undefined) {
      window.clearInterval(forcedIntervalId);
      newTimer.forcedIntervalId = window.setInterval(() => {
        maybeExecuteTimerCallback({ timerId, forced: true });
      }, forcedInterval);
    }

    timers.set(timerId, newTimer);
    callback({ forced, timerId });
  };

  const setInterval = ({ callback, interval, forcedInterval } = {}) => {
    const timerId = globalTimerId;

    if (typeof callback !== 'function' || typeof interval !== 'number') {
      return undefined;
    }

    const timer = {
      callback,
      interval,
      lastExecution: Date.now(),
    };

    if (forcedInterval !== undefined) {
      timer.forcedInterval = forcedInterval;
      timer.forcedIntervalId = window.setInterval(() => {
        maybeExecuteTimerCallback({ timerId, forced: true });
      }, forcedInterval);
    }

    timers.set(timerId, timer);
    globalTimerId += 1;
    return timerId;
  };

  const clearInterval = (timerId) => {
    const timer = timers.get(timerId);

    if (timer === undefined) {
      return;
    }

    const { forcedIntervalId } = timer;

    if (forcedIntervalId !== undefined) {
      window.clearInterval(forcedIntervalId);
    }

    timers.delete(timerId);
  };

  const runTimersCheck = () => {
    timers.forEach((_timer, timerId) => {
      maybeExecuteTimerCallback({ timerId });
    });
  };

  return {
    setInterval,
    clearInterval,
    runTimersCheck,
  };
};

export default ActivityBasedTimer;
Enter fullscreen mode Exit fullscreen mode

The timer exposes an interface for running the code in an interval (just like setInterval does), but with no guarantee that the code will actually execute at that interval. It instead also exposes a function one should call to check for all timers and execute them as necessary. It basically loops through all the intervals, checks when they were last executed, and if more time than what is defined as an interval time has passed, it executes the callback. There is an additional, third parameter, in the setInterval implementation that takes a "forced" interval. This interval uses a native setInterval function so it more or less provides a guarantee of running every x milliseconds.

We then used this interval to check for updates periodically:

import ActivityBasedTimer from '@/services/activityBasedTimer';

const versioningTimer = new ActivityBasedTimer();

versioningTimer.setInterval({
  async callback() {
    const newVersionAvailable = await isNewerVersionAvailable();

    if (!newVersionAvailable) {
      return;
    }

    store.commit('setNewVersionAvailable', true);
  },
  // Normal interval is once every 10 minutes
  interval: 1000 * 60 * 10,
  // Forced interval is once per day
  forcedInterval: 1000 * 60 * 60 * 24,
});
Enter fullscreen mode Exit fullscreen mode

Remember the function you need to call to check for the timers? We use that one in the router:

router.beforeEach((to, from, next) => {
  versioningTimer.runTimersCheck();
  next();
});
Enter fullscreen mode Exit fullscreen mode

We bound it to the router route change because that's a sign of people actively using the app.

Conclusion

When we deploy a new version, after a few minutes, those people that did not close the tab in the meantime will get a notification telling them to click to update (which again is just a basic window.location.reload(true)).

How do you approach this problem in your SPAs? I would like to hear from you. Feedback on our solution is also more than welcome.

Finally, if you want a chance to solve cool challenges like these, Homeday is hiring. Come join us in Berlin!

💖 💪 🙅 🚩
originalexe
Ante Sepic

Posted on December 3, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related