App Center

How to make PWAs re-engageable using Notifications and Push

Having the ability to cache the contents of an app to work offline is a great feature. Allowing the user to install the web app on their home screen is even better. But instead of relying only on user actions, we can do more, using Push messages and Notifications to automatically re-engage and deliver new content whenever it is available.

Two APIs, one goal

The Push API and Notifications API are two separate APIs, but they work well together when you want to provide engaging functionality in your app. Push is used to deliver new content from the server to the app without any client-side intervention, and its operation is handled by the app's Service Worker. Notifications can be used by the Service Worker to show new information to the user, or at least alert them when something has been updated.

They work outside of the browser window, just like Service Worker, so updates can be pushed and notifications can be shown when the app's page is out of focus or even closed.

Notifications

Let's start with Notifications — they can work without Push, but are very useful when combined with them. Let’s look at them in isolation to begin with.

Request permission

To show a notification, we have to request permission to do so first:

Notification.requestPermission().then(function(result) {
    if(result === 'granted') {
      randomNotification();
    }
});

This will show a popup using the operating system’s own notifications service:

When the user confirms that they are interested in receiving notifications, we can then show them. The result of the user action can be default, granted or denied. The default option is chosen when the user won't make a choice, and the other two are set when the user clicks yes or no respectively.

When accepted, the permission works for both Notifications and Push.

Create a notification

Our example app creates a notification out of the available data — a game is picked at random, and the chosen one feeds the notification with the content: it sets the game's name as the title, mentioning the author in the body, and showing the image as an icon:

function randomNotification() {
    var randomItem = Math.floor(Math.random()*games.length);
    var notifTitle = games[randomItem].name;
    var notifBody = 'Created by '+games[randomItem].author+'.';
    var notifImg = 'data/img/'+games[randomItem].slug+'.jpg';
    var options = {
        body: notifBody,
        icon: notifImg
    }
    var notif = new Notification(notifTitle, options);
    setTimeout(randomNotification, 30000);
}

A new random notification is created every 30 seconds until it becomes too annoying and is disabled by the user. The advantage of the Notification API is that it displays a system notification using the operating system's notification functionality. This means that notifications can be displayed to the user even when they are not looking at the web app.

Push

Push is more complicated than Notifications — we need to subscribe to a server that will then send the data back to our app. Our app's Service Worker will receive data from the push server, which can then be shown using the notifications system, or another mechanism if desired.

The technology is still at a very early stage — some working examples use the Google Cloud Messaging platform, but are being rewritten to support VAPID (Voluntary Application Identification), which offers an extra layer of security for your app. You can examine the Service Workers Cookbook examples, try to set up a push messaging server using Firebase, or build your own server (using Node.js for example).

As mentioned before, to be able to receive Push messages you have to have a Service Worker, the basics of which are already explained in the Making PWAs work offline with Service workers article. Inside the service worker, a push service subscription mechanism is created.

registration.pushManager.getSubscription() .then( /* ... */ );

Once the user is subscribed, they can receive push notifications from the server.

From the server-side, the whole process has to be encrypted with public/private keys for security reasons — allowing everyone to send push messages unsecured using your app would be a terrible idea. See the Web Push data encryption test page for detailed information about securing the server. The server will store all the information received when the user subscribed, so the messages can be sent later on when needed.

To receive push messages, we can listen to the push event in the Service Worker file:

self.addEventListener('push', function(e) { /* ... */ });

The data can be retrieved and then shown as a notification to the user immediately. This, for example, can be used to remind the user about something, or them him know about new content being available in the app.

Push example

Push needs the server part to work, so we're not able to include it in the js13kPWA example hosted on GitHub Pages, as it offers hosting of static files only. It is all explained in the Service Worker Cookbook — see the Push Payload Demo.

This demo consists of three files:

  • index.js, which contains the source code of our app
  • server.js, which contains the server part (written in Node.js)
  • service-worker.js, which contains the Service Worker-specific code.

Let's explore all of these

index.js

The index.js file starts by registering the Service Worker:

navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
  return registration.pushManager.getSubscription()
  .then(async function(subscription) {
      // registration part
  });
})
.then(function(subscription) {
    // subscription part
});

It is a little bit more complicated than the service worker we saw in the js13kPWA demo. In this particular case, after registering we use the registration object to subscribe, and then move use the resulting subscription object to complete the whole process.

In the registration part, the code looks like this:

if(subscription) {
    return subscription;
}

If a user has already subscribed, we then return the subscription object and move to the subscription part. If not, we initialize a new subscription:

const response = await fetch('./vapidPublicKey');
const vapidPublicKey = await response.text();
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);

The app will fetch the server's public key and convert the response to text, then it needs to be converted to a Uint8Array (to support Chrome). To learn more about VAPID keys you can read the Sending VAPID identified WebPush Notifications via Mozilla’s Push Service blog post.

The app can now use the PushManager to subscribe the new user. There are two options passed to the PushManager.subscribe() method — the first is userVisibleOnly: true, which means all the notifications sent to the user will be visible to them, and the second one is the applicationServerKey, which contains our successfully acquired and converted VAPID key.

return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: convertedVapidKey
});

Now let's move to the subscription part — the app will first send the subscription details as JSON to the server using Fetch.

fetch('./register', {
    method: 'post',
    headers: {
        'Content-type': 'application/json'
    },
    body: JSON.stringify({
        subscription: subscription
    }),
});

Then the GlobalEventHandlers.onclick function on the Subscribe button is defined:

document.getElementById('doIt').onclick = function() {
    const payload = document.getElementById('notification-payload').value;
    const delay = document.getElementById('notification-delay').value;
    const ttl = document.getElementById('notification-ttl').value;

    fetch('./sendNotification', {
        method: 'post',
        headers: {
            'Content-type': 'application/json'
        },
        body: JSON.stringify({
            subscription: subscription,
            payload: payload,
            delay: delay,
            ttl: ttl,
        }),
    });
};

After the button is clicked, fetch will ask the server to send the notification with the given parameters: payload is the text that will be shown in the notification, delay defines a delay in seconds until the notification will be shown, and ttl is the time-to-live setting that will keep the notification available on the server for a specified amount of time, also defined in seconds.

Now, onto the next JavaScript file.

server.js

The server part is written in Node.js and needs to be hosted somewhere suitable, which is a subject for an entirely separate article. We will only provide a top-level overview here.

The web-push module is used to set the VAPID keys, and optionally generate them if they are not available yet.

const webPush = require('web-push');

if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
  console.log("You must set the VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY "+
    "environment variables. You can use the following ones:");
  console.log(webPush.generateVAPIDKeys());
  return;
}

webPush.setVapidDetails(
  'https://serviceworke.rs/',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

Next, a module defines and exports all the routes an app will need to handle: getting the VAPID public key, registering, and then sending notifications. You can see the variables from the index.js file being used: payload, delay and ttl.

module.exports = function(app, route) {
  app.get(route + 'vapidPublicKey', function(req, res) {
    res.send(process.env.VAPID_PUBLIC_KEY);
  });

  app.post(route + 'register', function(req, res) {

    res.sendStatus(201);
  });

  app.post(route + 'sendNotification', function(req, res) {
    const subscription = req.body.subscription;
    const payload = req.body.payload;
    const options = {
      TTL: req.body.ttl
    };

    setTimeout(function() {
      webPush.sendNotification(subscription, payload, options)
      .then(function() {
        res.sendStatus(201);
      })
      .catch(function(error) {
        console.log(error);
        res.sendStatus(500);
      });
    }, req.body.delay * 1000);
  });
};

service-worker.js

The last file we will look at is the Service Worker:

self.addEventListener('push', function(event) {
    const payload = event.data ? event.data.text() : 'no payload';
    event.waitUntil(
        self.registration.showNotification('ServiceWorker Cookbook', {
            body: payload,
        })
    );
});

All it does is add a listener for the push event, create the payload variable consisting of the text taken from the data (or create a string to use if data is empty), and then wait until the notification is shown to the user.

Feel free to explore the rest of the examples in the Service Worker Cookbook if you want to know how they are handled — the full source code is available on GitHub. There's a big collection of working examples showing general use, but also Web Push, Caching strategies, performance, working offline, and more.

Conclusion

That's it for this tutorial series — we went through the source code of our js13kPWA example app and learned about the use of Progressive Web Apps features including an Introduction, PWA structure, offline availability with Service Workers, installable PWAs, and finally Notifications. We also explained Push with help from the Service Worker Cookbook.

Feel free to experiment with the code, enhance your existing app with PWA features, or build something entirely new on your own. PWAs give a huge advantage over regular web apps.

Document Tags and Contributors

 Contributors to this page: chrisdavidmills
 Last updated by: chrisdavidmills,