Quick Links

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.

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.

Screenshot of a Microsoft Edge push notification on Windows 10

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

})

});

}

}

}

}

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.

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.

Screenshot showing an example banner asking a user to enable push notifications on a website

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

}

}

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.

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.

Screenshot of a Microsoft Edge push notification on Windows 10

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

});

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.