X
Popular Searches

How to Handle Web Push Notifications in Websites and PWAs

Photo of a smartphone with notification icons appearing to come out of it
RoBird/Shutterstock.com

Push notifications are a common sight on the modern web. They let you communicate timely information to the user, even if your site’s not actually open. The user’s browser handles incoming push events and displays notifications using system UI surfaces like the Windows Action Center and Android lockscreen.

Implementing Web Push into your site or PWA requires the combination of two distinct browser APIs. The code responsible for subscribing to and receiving notifications uses the Push API component of service workers. This code runs continually in the background and will be invoked by the browser when a new notification needs to be handled.

When an event’s received, the service worker should use the Notification API to actually display the notification. This creates a visual alert via OS-level interfaces.

Here’s a complete guide to getting Web Push working on your site. We’ll assume you’ve already got a server-side component that can register push subscriptions and send out your alerts.

The Service Worker

Let’s start with the service worker. Service workers have multiple roles – they can cache data for offline use, run periodic background syncs, and act as notification handlers. Service workers use an event-driven architecture. Once registered by a site, the user’s browser will invoke the service worker in the background when events it subscribes to are generated.

Advertisement

For Web Push, one core event is needed: push. This receives a PushEvent object that lets you access the payload pushed from the server.

self.addEventListener("push", e => {
 
    const payload = JSON.parse(e.data.text());
 
    e.waitUntil(self.registration.showNotification(
        payload.title,
        {
            body: payload.body,
            icon: "/icon.png"
        }
    ));
 
});

The code above sets up a service worker capable of reacting to incoming push events. It expects the server to send JSON payloads looking like this:

{
    "title": "Title text for the notification",
    "body": "This is the longer text of the notification."
}

When a push event is received, the service worker displays a browser notification by calling the showNotification() function available on its self.registration property. The function’s wrapped in a waitUntil() call so the browser waits for the notification to be displayed before terminating the service worker.

The showNotification() function takes two arguments: the notification’s title text and an options object. Two options are passed in this example, some longer body text and an icon to display in the notification. Many other options are available that let you setup vibration patterns, custom badges, and interaction requirements. Not all browsers and operating systems support all the capabilities exposed by the API.

Complete the service worker side of the code by registering it back in your main JavaScript:

if (navigator.serviceWorker) {
    // replace with the path to your service worker file
    navigator.serviceWorker.register("/sw.js").catch(() => {
        console.error("Couldn't register the service worker.")
    });
}

This code should run on each page load. It makes sure the browser supports service workers and then registers the worker file. Browsers will automatically update the service worker whenever the server copy exhibits byte differences to the currently installed version.

Registering for Push Subscriptions

Now you need to subscribe the browser to push notifications. The following code belongs in your main JavaScript file, outside the service worker.

async function subscribeToPush() {
    if (navigator.serviceWorker) {
 
        const reg = await navigator.serviceWorker.getRegistration();
 
        if (reg && reg.pushManager) {
 
            const subscription = await reg.pushManager.getSubscription();
 
            if (!subscription) {
 
                const key = await fetch("https://example.com/vapid_key");
                const keyData = await key.text();
 
                const sub = await reg.pushManager.subscribe({
                    applicationServerKey: keyData,
                    userVisibleOnly: true
                });
 
                await fetch("https://example.com/push_subscribe", {
                    method: "POST",
                    headers: {"Content-Type": "application/json"},
                    body: JSON.stringify({
                        endpoint: sub.endpoint,
                        expirationTime: sub.expirationTime,
                        keys: sub.toJSON().keys
                    })
                });
 
            }
 
        }
 
    }
}
Advertisement

Then call your function to subscribe the browser to push notifications:

await subscribeToPush();

Let’s walk through what the subscription code is doing. The first few lines check for the presence of a service worker, retrieve its registration, and detect push notification support. pushManager won’t be set in browsers which don’t support Web Push.

Calling pushManager.getSubscription() returns a promise that resolves to an object describing the browser’s current push subscription for your site. If this is already set, we don’t need to resubscribe the user.

The real subscription flow begins with the fetch request for the server’s VAPID keys. The VAPID specification is a mechanism which lets the browser verify push events are actually coming from your server. You should expose a server API endpoint that provides a VAPID key. This is given to the pushManager.subscribe() function so the browser knows the key to trust. The separate userVisibleOnly option indicates we’ll only display notifications that visibly display on the screen.

The pushManager.subscribe() call returns a PushSubscription object describing your new subscription. This data is sent to the server in another fetch request. In a real app, you’d also send the active user’s ID so you could link the push subscription to their device.

Advertisement

Your server-side code for sending a push notification to a user should look something like this:

  1. Query your data store for all push subscriptions linked to the target user.
  2. Send your notification payload to the endpoint indicated by each subscription, making sure to include the subscription’s authentication keys (keys in the data sent by the browser when subscribing). Sign the event with the same VAPID key you sent to the browser.

Each subscription’s endpoint will reference the browser vendor’s notification delivery platform. This URL already includes a unique identifier for the subscription. When you send a payload to the endpoint, the browser’s background process will eventually receive the data and invoke your service worker. For Chrome on Android, the browser process is directly integrated with the system notification daemon.

When to Subscribe the User?

When setting up subscription flows, remember the user will have to acknowledge a browser permission prompt before registration completes. Many browsers automatically hide or reject unsolicited permission requests; in any case, asking a user to subscribe the moment they land on your site may not deliver the result you want.

You get the best chance of a successful sign-up by coupling subscription requests to a direct user action. Consider providing an in-app banner that explains the benefits of enabling notifications and offers an “Enable Now” button. You can check whether the user’s already subscribed and hide the banner with the pushManager.getSubscription() function shown above.

Clicking the enable button should call your subscription function. The process could take a few seconds while the browser sets up the registration and your network calls complete. Displaying a loading spinner during this time will help keep the user informed.

Users should also be given a way to unsubscribe. Although they can revoke the browser permission at any time, some users will look for an in-app option, especially if they’ve installed your site as a PWA.

Here’s a simple unsubscribe implementation:

async function unsubscribePush() {
 
    const reg = await navigator.serviceWorker.getRegistration();
    const subscription = await reg.pushManager.getSubscription();
 
    if (subscription) {
        await subscription.unsubscribe();
        await fetch(`https://example.com/push_unsubscribe/${subscription.endpoint}`, {method: "DELETE"});
    }
    else {
        // already subscribed
    }
 
}
Advertisement

Calling unsubscribe() on a PushSubscription cancels the subscription, reverting the browser to its default state. Your service worker will stop receiving push events. The subscription’s endpoint is sent to your server so you can remove it from your data store and avoid sending data to what’s now a dead URL.

Handling Expirations and Renewals

You might have noticed the expirationTime property on the PushSubscription object created by the browser. This won’t always be set; when it is, the device will stop receiving notifications after this time.

In practice, expirationTime isn’t currently used in major browsers. Tokens produced by Chrome don’t expire until manually unsubscribed so expirationTime is always null. Firefox doesn’t set expirationTime either but its notification service can replace subscriptions during their lifetime.

You can respond to the browser changing your active push subscription by implementing the pushsubscriptionchange event in your service worker. Unfortunately there are two versions of this event: the original implementation, currently used by Firefox, and the new v2, not yet supported in any browser.

The original spec has serious usability issues which make it difficult to respond to the event. When you receive a v1 event, the browser has deleted the original subscription and you need to manually create a new one. The problem is without access to the expired subscription you can’t issue a “replace” request to your server – you’ve got no way of accessing the old endpoint URL.

The v2 spec solves this by providing an event with oldSubscription and newSubscription properties. When you receive the event, the old subscription has been canceled but you can still access its properties. The new subscription is now created for you by the browser.

Advertisement

Here’s an example of implementing pushsubscriptionchange with the new spec:

self.addEventListener("pushsubscriptionchange", e => {
    e.waitUntil(async () => {
        await fetch("https://example.com/push_change", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                auth: (e.newSubscription.toJSON().keys?.auth || null),
                endpoint: e.newSubscription.endpoint,
                endpointOld: e.oldSubscription.endpoint,
                expirationTime: e.newSubscription.expirationTime,
                p256dh: (e.newSubscription.toJSON().keys?.p256dh || null)
            })
        });
    });
});

Endpoints are unique so your server can lookup the old subscription and update its properties with those of the new subscription. If you want to add support for the old spec too, you’ll need to manually track the active subscription endpoint outside of the push API. Storing it into localStorage or IndexedDB will let you access it inside your pushsubscriptionchange handler so you can ask the server to replace the subscription.

The revised spec is much easier to implement than its older counterpart. Even though it’s not yet supported in browsers, it’s worth adding it to your service worker anyway. A few lines of code will future-proof your push handling against new browser releases.

Adding Action Buttons

Push notifications can include interactive buttons that let the user take immediate actions. Here’s a showNotification() call which creates one:

self.registration.showNotification(
    "Notification with actions",
    {
        body: "This notification has a button.",
        actions: [
            {
                action: "/home",
                title: "Go to Homescreen",
                icon: "/home.png"
            }
        ]
    }
);

Each notification can include multiple actions, each with a label, icon and action. The latter property should identify an action your app can initiate in response to the user’s press.

When the user taps an action, your service worker receives a notificationclick event:

self.addEventListener("notificationclick", e => {
    const uri = e.action;
    const notification = e.notification;
    notification.close();
    clients.openWindow(`${self.location.origin}${action}`);
});
Advertisement

We’re using the action property to declare a URI the user can navigate to. A new tab’s opened to the URI when the notification is pressed. Calling notification.close() ensures the notification is dismissed too. Otherwise, some platforms will make the user manually swipe it away.

Summary

Implementing Web Push can seem daunting if you’ve not worked with the relevant APIs before. More than the technical concerns, you should keep the user experience at the forefront of your mind and make sure you communicate why it’s worth enabling notifications.

Subscribing and unsubscribing to push occurs in your application’s main JavaScript code, using navigator.serviceWorker APIs. The code that responds to new push events and displays browser notifications lives in the service worker itself.

Web Push is now supported by most major web browsers, Safari being the prominent exception. Remember that notifications will render differently in each browser and operating system family so don’t assume that a particular feature of the showNotification() API will be universally available.

James Walker James Walker
James Walker is a CloudSavvy IT contributor. He is the founder of Heron Web, a UK-based digital agency providing bespoke software development services to SMEs. He has experience managing complete end-to-end web development workflows with DevOps, CI/CD, Docker, and Kubernetes. Read Full Bio »

The above article may contain affiliate links, which help support CloudSavvy IT.