DEV Community

Cover image for Can you please refresh (Or how we version our Single-Page Application)
Ante Sepic
Ante Sepic

Posted on

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;

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,
});

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();
});

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!

Discussion (12)

Collapse
willvincent profile image
Will Vincent • Edited on

We have netlify hit a route on our backend that then in turn sends a special message via MQTT to all currently active users, which causes an "An update is ready, please click to load the new version" message to appear at the bottom of their screen.

Works great, super painless thanks to that netlify webhook

Collapse
originalexe profile image
Ante Sepic Author

Nice. Did you already have the backend -> frontend communication set up, or did you add it specifically for the purpose of the update notice?

As mentioned in the article, it seemed overkill to us to add a completely new communication system just for this purpose.

Collapse
layzee profile image
Lars Gyrup Brink Nielsen

I liked your considerations and approach. But isn't this is something that is already solved by a service worker? Events for update notifications and triggering of updates.

Collapse
originalexe profile image
Ante Sepic Author • Edited on

Hey Lars, thanks for the feedback.

As far as I am aware, a service worker checks for the new version availability on page load, but we needed the checks for people who were idle, or who were just browsing the app without refreshing.

Additionally, even if it would technically do what we needed it to, we were not making use of the service worker in that particular spa, so adding an additional script just for the update detection mechanism did not seem worth it.

Lastly, we also support IE11 on our app, so that was taken into consideration as well.

Collapse
layzee profile image
Lars Gyrup Brink Nielsen • Edited on

I see. What about this issue, how are you going to handle that?

Without service workers, users can load one tab to your site, then later open another. This can result in two versions of your site running at the same time. Sometimes this is ok, but if you're dealing with storage you can easily end up with two tabs having very different opinions on how their shared storage should be managed. This can result in errors, or worse, data loss.
From developers.google.com/web/fundamen...

Registered service workers have an update method to check for updates manually.

Thread Thread
originalexe profile image
Ante Sepic Author • Edited on

Actually, in this case, we actually prefer to notify the user about the update in every tab.
It would be bad to not show the notification in every tab (we ideally don't want users to run the old version in some of them), but we also can't refresh all tabs at once (some of them might have data that the user does not want to lose).

I hope that makes sense, but let me know if I am missing your point.

Collapse
gauravvermamaropost profile image
gaurav-verma-maropost

How did you solve the issue of excluding the index.html file from service-worker for having the new index.html all the time directly from the server and not form service-workers or disk cache. Also if possible could you share the function isNewerVersionAvailable()

Collapse
originalexe profile image
Ante Sepic Author • Edited on

Hi,

This app did not make use of service workers, so we did not have to implement any workarounds. Since the headers on Cloudfront were set so that html files were never cached, there were never any cache-related problems.

Also if possible could you share the function isNewerVersionAvailable()

I have since changed jobs so I no longer have access to the source code, but the logic was something like:

let oldSource = null;

async function isNewerVersionAvailable() {
  const freshSource = await fetch(...);

  if (oldSource === null) {
    oldSource = freshSource;
    return false;
  }

  if (oldSource === freshSource) {
    return false;
  }

  oldSource = freshSource;
  return true;
}
Collapse
gauravvermamaropost profile image
gaurav-verma-maropost

Thanks. Just one question why didn't you considered calling some other smaller file's response headers time stamp by fetching that one. That could have saved some data per request.

Thread Thread
originalexe profile image
Ante Sepic Author

The fact is that our HTML file was already rather small (2.57kb gzip), so while fetching for example just file headers would have been less expensive in terms of bandwidth, I think we were rather happy with that approach. If I were to do it again, I would test the headers approach and go with that if it was equally reliable.

Collapse
testarossaaaaa profile image
Testarossa

What do you do with problematic extensions that modifies content on the fly? Users will see a 'Reload to update' notification every tick of the timer. We are currently facing this problem.

Collapse
originalexe profile image
Ante Sepic Author

Hi,

We did not have any reports of this. How are you fetching the HTML? Are you relying on innerHTML initially? Most of the extensions will mess with that, but not with the response of the network request itself, so if you only rely on what fetch gives you, I believe you should be fine in most cases.

If that's still a problem, you could rely on the "last-modified" header instead. We serve this app via S3 + CloudFront, and it sends the last-modified header. You can then access it and compare that instead of the whole HTML.