Long gone are the days of push notifications being an exclusive feature of native mobile applications. With the advent of the Push API, web browsers now have the ability to receive notifications as well. Users can even receive notifications when they don’t have your website open. We have recently implemented support on meetme.com.
What is the current browser support?
Web push works on both desktop and mobile; however, not all browsers support the standard yet.
- Chome 50+ (both desktop and mobile Android)
- Chrome 44 introduced partial support but notification payloads were not supported
- Firefox 44+ desktop, Firefox 48+ mobile Android
- Safari does NOT support web push (although the desktop version supports Apple’s own proprietary protocol for push)
- Coming soon to Microsoft Edge
High level overview
From the perspective of a user, web push notifications work largely the same as on native mobile. However, before a user can start receiving push notifications they must accept permissions for your website. When a user arrives at a website that supports web push (and they are using a browser that supports it), the website can then prompt the user to enable notifications:
From this point forward, whenever the website has a notification it will make a REST call to the push provider (more on this below), which will in turn generate the notification on the user’s device. Note that the user does not need to have the website open in order to receive notifications.
Behind the scenes the website needs to implement a service worker which will handle receiving the push event and generating a notification UI element.
Components of Web Push
Service Workers
A service worker runs in the background separately from your web application and is required in order to receive push notifications. It has no direct access to the DOM and any communication with your web application must occur using postMessage() to send and receive messages. Some other aspects of service workers include:
- Can be stopped/restarted by the browser as necessary (you cannot depend on global state)
- Has access to the IndexedDB API
- Does NOT have access to LocalStorage or cookies
- Receives the actual push notification message
- Generates the visible notification message and handles the click
Use of Promises
The API for service workers relies heavily on promises, so you will need to have some familiarity with them. In a nutshell, promises are designed to make working with asynchronous code easier by wrapping success and error callbacks in chainable functions to allow for greater readability. Although this article is not going to give a tutorial on promises, some good documentation can be found here. One thing that should be noted, however, is that if you are accustomed to using promises in jQuery or Q, the interface for JavaScript ES6 Promises is slightly different. For example, here is how you might use a promise in jQuery:
function wait() { var deferred = $.Deferred(); window.setTimeout(function() { deferred.resolve('Wait complete!'); }, 5000); return deferred.promise(); } wait() .then(function(message) { console.log(message); }) .fail(function(err) { console.log('Error occurred', err); });
The preceding code will wait for 5 seconds and then print “Wait Complete!” to the JavaScript console. Let’s look at the equivalent using ES6 Promises:
function wait() { return new Promise(function(resolve, reject) { window.setTimeout(function() { resolve('Wait complete!'); }, 5000); }); } wait() .then(function(message) { console.log(message); }) .catch(function(err) { console.log('Error occurred', err); });
As you can see, the concept is the same but the syntax is slightly different. It should also be noted that, unlike jQuery, an ES6 Promise does not have a done() method.
How is a service worker used?
A service worker needs to be registered; however, we must first check for browser support.
if ('serviceWorker' in navigator) { // The location of our service worker. We will write // the code for "serviceWorker.js" later on. navigator.serviceWorker.register(‘serviceWorker.js’); // We’ll define the ‘handleServiceWorkerRegistered’ function below navigator.serviceWorker.ready.then(handleServiceWorkerRegistered); }
Once our service worker is registered we will need to subscribe via the PushManager in order to start receiving push notifications.
function handleServiceWorkerRegistered(serviceWorkerRegistration) { serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly : true }) .then(handlePushSubscription) .catch(function(err) { log('Unable to get push subscription', err); }); }
For users who have not previously enabled notifications a prompt will appear.
Note that if you want to check for the existence of a push subscription prior to invoking the prompt, you can do so by calling getSubscription() on the PushManager instance instead. If the Promise it returns resolves to a null value then there is no existing subscription. Calling getSubscription() first can be useful if you want to display your own custom message to better inform the user of the enable notifications prompt they are about to see.
It’s possible to determine if the user has pressed cancel on the “enable notifications” prompt if you call subscribe(); the error thrown will have a “name” property of “PermissionDeniedError”.
You may have noticed that when we called subscribe() a “userVisibleOnly” property was specified as true. This is currently required in Chrome. The option indicates that for every push notification received we will show a notification. If you were to not generate a visible notification when a push message was received then the browser will generate a default notification with the text “This site has been updated in the background.” This can also occur if you attempt to perform an asynchronous operation prior to showing the notification without returning a promise.
In our handlePushSubscription() function, we will receive a single argument of type PushSubscription. This contains the data that is required in order for our backend to generate push notifications. The data that PushSubscription contains is a bit different from what you may be used to if you’ve worked with native mobile push notifications (which generate a token hash string). Here is an example of how it is formatted if we stringify the PushSubscription object:
{ "endpoint": "https://android.googleapis.com/gcm/send/f1wiUcAvwJs:APA91bVXB4zTKLi3SVWjP7U…gq0r-snSShoyDXDgPhHkzDSCHZIBv2lHlCgqXuQNV8XE-b-hqOd_HjvETW7Q5tPXm98nmjZYo9", "keys": { "p256dh":"BN66uhtXH-Iw6ejdxKpbnmGhzNwye59W_ky3gJNQBMLWT5R1CuUI2vg3khGO8Xz8Tc44Hf6bbg0-z9pDXUKGAqQ=", "auth":"Vj2YRNv7qXQyI2lRb9mZrw==" } }
The “keys” portion is what is used to encrypt the payload of the push message. There are libraries that can handle this portion for you (we’ll show an example later on).
The “endpoint” property is unique and is used to identify your browser. Endpoints take the following form (where “unique-key” is a key that identifies the user’s specific browser):
Chrome
https://android.googleapis.com/gcm/send/unique-key
FireFox
https://updates.push.services.mozilla.com/wpush/v1/unique-key
Now let’s define our “handlePushSubscription” function. Its job is going to be informing our backend of the push subscription (which can then be used to generate notifications to the browser itself).
function handlePushSubscription(pushSubscription) { // Pass the push subscription data to our backend $.ajax({ url: 'https://api.example.com/setPushSubscription', type: 'POST', data: { pushData: JSON.stringify(pushSubscription) } }); }
The URL endpoint used above will need to store our web push subscription data (and associate it with the current user).
Generating Push Notifications on our backend
When we have a notification that we would like to send to our user, we will need to encrypt the notification’s payload and make a POST request to the endpoint that we received. To accomplish this, we’re going to use Node.js and the excellent web-push package.
var webpush = require('web-push'), // Where getUserPushSubscription() returns the data we // submitted to api.example.com/setPushSubscription above: // { // endpoint: '...', // keys: { // auth: '...', // p256dh: '...' // } // } userPushSubscription = getUserPushSubscription(); // This is only necessary for Chrome webpush.setGCMAPIKey(myGCMKey); // This can be formatted as you like var payload = { message : 'You have a notification!' }; webpush .sendNotification(userPushSubscription, JSON.stringify(payload)) .then(function() { console.log('Push notification sent to user!'); }) .catch(function(err) { if (err.code === 'expired-subscription') { // The user's push token data is no longer valid // and should be discarded. } else { console.log('Error occurred!', err); } });
Chrome requires some additional setup steps such as defining an application manifest with a “gcm_sender_id” (for more details see here).
Handling Push Notifications in our Service Worker
We’re now going to setup the “serviceWorker.js” that we referenced earlier. This script is going to run in the background listening for notifications.
self.addEventListener('push', handlePushNotification); self.addEventListener('notificationclick', handleNotificationClick);
There are other useful events that we can listen for in a service worker (see a more thorough guide here). The “push” event is fired when a push notification is received by the browser. You do not need to have a tab open (of the web application) to receive a notification. Let’s define our event handlers:
function handlePushNotification(evt) { var payload = evt.data.json(); // Let's generate a notification the user will see. evt.waitUntil( self.registration.showNotification( 'Notification recieved', // Title { // The message we sent above using "web-push". body : payload.message, icon : 'http://images.example.com/notification.jpg', data : { // This will be available to our notification // click handler. } }) ); } function handleNotificationClick(evt) { evt.waitUntil( // Open a new tab. clients.openWindow('http://example.com/view-notification'); evt.notification.close(); ); }
Note that the notification click handler above will open a new browser tab regardless of whether or not one is already open (there are ways around this by finding an existing client tab using clients.matchAll(), sending it a message via postMessage(), and then handling the message in the web application).
Conclusion
Browser adoption of the push standard has taken an important step towards moving the web platform forward. Although the discussion of service workers in this article was limited to push notifications, service workers can be used for other purposes such as adding offline functionality. A full example of the code used here has been posted on Github.