PWA Update Notification with Create React App

Update notification screenshot

Table of Contents

When I first heard about progressive web apps (or PWAs) and their offline capabilities, the first question that came to my mind was how to gain control over which version the user is seeing.

What if an urgent bug-fix needs to be shipped as soon as possible to all users?

Here I'll first go over some basic concepts regarding progressive web apps and then I'll explain how to implement an "update available" notification for the user.

If you want to know more about PWAs, I recommend you check out my complete guide to Progressive Web Apps with React.

If you have any open questions, feel free to hit me up on Twitter or via Email (link in the footer).

I recently made a personal project of mine open-source, which also implements an update notification. Go check it out here.

Concepts around PWAs

App shell

In single-page applications without server-side rendering, usually, the first thing being loaded is the application code, which then populates the app with data fetched from the API. This is also called app shell architecture.

In other words, the "app shell" is everything that you would normally download from a mobile app store. In progressive web apps, we cache the app shell with Service Workers.

App Shell Example

Example of an app shell architecture. At this point, the data hasn't been loaded yet.

Caching strategies

Different caching strategies can be selected when working with progressive web apps and Service Workers. The most widely used strategy is the cache first strategy, which first serves the application from the Service Worker cache to ensure a fast and consistent user experience.

The most widely used caching strategy is the Cache First strategy, which will serve the app shell from the cache whenever possible.

Cache-First illustrated

The cache access failed and the Service Worker uses the network as a fallback.

This is the default caching strategy when implementing Service Workers.

To get a better understanding of how the application shell update works and at which point we can show the update notification to the user, it's important to have an overview of the lifecycle of a Service Worker:

Service Worker lifecycle

Service Worker Lifecycle

A Service Worker becomes redundant when it's being replaced by a new one or when installation or activation failed.

When a Service Worker is being installed for the first time, it usually runs through all these states directly until it is Activated.

However, when a new version is getting installed, it will get stalled in the Installed/waiting state, waiting for getting activated.

Now we can get the Service Worker to become active in a few different ways:

1. Close all tabs and open the website again

The Service Worker won't automatically pass on to available unless all tabs with the website open have been closed.

2. Call skipWaiting in the service-worker.js

This simply tells the Service Worker to install and activate new versions as soon as they are available.

3. Call skipWaiting on user interaction

That's when the user clicks on the button of the banner we'll provide.

Service Workers in Create-React-App

In Create React App you might have noticed that it already comes with a serviceWorker.ts file (I'm using the TypeScript version here).

Behind the scenes, CRA is using Workbox, a tool by Google that handles all the set up with an easy to use API. CRA is using its GenerateSW webpack plugin. This plugin autogenerates a service-worker.js that already precaches static assets and registers the navigation route.

The serviceWorker.ts file then registers this Service Worker by calling

navigator.serviceWorker.register(swUrl)

(swUrl points to the location of service-worker.js).

The part of the JS file that's most interesting to us is the following, which lets us tell the service worker to skip waiting and become active.

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Debugging

Before continuing with the actual implementation it makes sense to talk about how to work with this while developing. Create React App does a good job explaining this.

You need to run npm run build followed by serve -s build.

For triggering updates I found it easier to just modify the Service Worker file at build/service-worker.js. Adding a console.log statement already triggers an update since it changes the file hash.

Implementation

The solution is pretty simple and can be divided into two steps:

  1. Check if an update is available and show a message
  2. Tell the Service Worker to skip waiting and reload the page

Check if an update is available

Luckily, the register function of the Service Worker also provides an onSuccess and onUpdate callback. onUpdate will only be called when a new Service Worker has been successfully installed.

We can leverage this callback for adding the code that triggers the update notification. Before that we need to remove the registration call from the index.tsx to not call it twice:

const [showReload, setShowReload] = useState(false);
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);

const onSWUpdate = (registration: ServiceWorkerRegistration) => {
  setShowReload(true);
  setWaitingWorker(registration.waiting);
};

useEffect(() => {
  serviceWorker.register({ onUpdate: onSWUpdate });
}, []);

Make sure this component is always rendered, otherwise, the Service Worker won't get registered!

registration.waiting refers to the new worker. We save this Service Worker in the state of the component to use it to skip waiting in the second step.

In case you were wondering: This example is using React hooks. Calling useEffect without a second parameter is like calling componentDidMount and useState replaces React's state. Check out their documentation for more information on that topic. A full example can be found at the bottom.

Skip waiting and reload the page

At this point, we're already showing some kind of message but we're not able to update to the new version. The code for this is pretty straight forward:

const reloadPage = () => {
  waitingWorker?.postMessage({ type: 'SKIP_WAITING' });
  setShowReload(false);
  window.location.reload(true);
};

Here we're sending a message to the Service Worker to tell it to skip waiting and become active and then we do a hard reload of the page.

Putting it all together, we should have a component that looks like the following.

Now you just need to include it somewhere in your application where it always gets rendered and you're done.

Very important: You have to disable caching for the /service-worker.js file. Otherwise, the browser will not install the new Service Worker and your update notification might never show!

import React, { FC, useEffect } from 'react';
import { Snackbar, Button } from '@material-ui/core';
import * as serviceWorker from '../serviceWorker';

const ServiceWorkerWrapper: FC = () => {
  const [showReload, setShowReload] = React.useState(false);
  const [waitingWorker, setWaitingWorker] = React.useState<ServiceWorker | null>(null);

  const onSWUpdate = (registration: ServiceWorkerRegistration) => {
    setShowReload(true);
    setWaitingWorker(registration.waiting);
  };

  useEffect(() => {
    serviceWorker.register({ onUpdate: onSWUpdate });
  }, []);

  const reloadPage = () => {
    waitingWorker?.postMessage({ type: 'SKIP_WAITING' });
    setShowReload(false);
    window.location.reload(true);
  };

  return (
    <Snackbar
      open={showReload}
      message="A new version is available!"
      onClick={reloadPage}
      anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
      action={
        <Button
          color="inherit"
          size="small"
          onClick={reloadPage}
        >
          Reload
        </Button>
      }
    />
  );
}

export default ServiceWorkerWrapper;