Taha

How to Add Web Push Notifications to Your Web App

Sending push notifications is a great way to communicate with your site's users even when they are not currently using your site. We get push notifications all the time from various apps on our phones such as Twitter, Instagram, and the Mail app.

It's a great time to start using web push notifications as it's now supported in all major browsers—including iOS devices, which was recently supported in iOS 16.4.

In this article, I'm going to show you how to use web push notifications by building an example from scratch.

Before we dive in, I think it's worth mentioning that using push notifications inappropriately can be annoying to users and considered as a spam. These notifications should be sent for important updates that are timely, relevant, and precise. A good example is notifying the user when they receive a message to their inbox. A bad example is spamming the user with irrelevant ads and discounts, etc.

What is a push notification?

Web push notifications are the combination of two different technologies: Push Messages and Notifications.

A notification is the notification card you see on your phone's home screen or on your desktop, at the top-right corner of your screen.

Desktop notification Mobile notification

A push message, on the other hand, is the technology for sending a message from a server to a client. It's called push because it pushes the message without the client asking for it—which is the opposite of the traditional request/response workflow.

So when I say push notification, I mean the workflow of pushing a message from the server to the client and then display it on the client side using the notification card component.

A general overview of the web push notification workflow

There are 6 main steps for sending a push notification:

  1. Request permission from the user to display notifications.
  2. Subscribe the user to your push notification service. This step gets you an object called PushSubscription. This object is used to identify which client to send the notification to. Each client has a different PushSubscription object. I'm saying a client, not a user, because a client is the browser the user is using. A user can have multiple clients—their desktop browsers (each browser is a different client) and their mobile browsers.
  3. Store that PushSubscription object somewhere on your backend—in the database for example. You need to associate that object with the user of your application—for example store it as a new field in the users table; this depends on your application. To support multiple browsers for the user, you need to store a list of PushSubscription objects instead of one.
  4. Get the PushSubscription object(s) of the user you want to notify, and then send them the message.
  5. Listen to the push event in the service worker of your site. Remember service workers run on the frontend side of the site.
  6. When the push event is triggered, get the message included in the notification sent from the backend, and then display it as a notification using the Notification API.

The first three steps are only run once for each notification subscription. Once the user is subscribed, it will run the last three steps for each notification you send.

Display a test notification

To display a notification, you need to get permission from the user first. To do that, you need to run this from your JavaScript:

Notification.requestPermission().then((permissionResult) => {
  if (permissionResult === 'granted') {
    console.log('Can show notifications')
  } 
})

Calling this function returns a promise with the permission result. The returned result can be one of these strings: default, granted, or denied. The default value is used when the user doesn't click "Allow" or "Block", and instead clicks the close button of the permission prompt.

In some browsers, like Safari, requesting notifications permission requires you to run it from a user gesture, meaning that the user has to do something first to allow for notifications, like clicking a button.

So, to test the above code, put it in a button click handler, like this:

<body>
  <button class="show-notification-button">
    Show Notification
  </button>

  <script>
    const button = document.querySelector('.show-notification-button')

    button.addEventListener('click', () => {
      Notification.requestPermission().then((permissionResult) => {
        if (permissionResult === 'granted') {
          console.log('Can show notifications')
        }
      })
    })
  </script>
</body>

After clicking "Show Notification", the browser will ask you if you want to allow notifications from this site or not.

If you click "Allow", you will see "Can show notifications" logged into your console.

After getting the permission to show notifications, you can start showing notifications using service workers.

Note that installing a service worker on your site is needed for listening to push events and showing notifications.

You can think of a service worker as JavaScript code that runs in the background. This means, the code in service workers can run even if your site isn't loaded or if the browser's window is closed—that's all you need to know about service workers for this tutorial, but you can learn more on MDN.

To use a service worker, you have to add it and then register it.

To add it, add a new empty file in your root directory and call it sw.js (or any name you want).

And then register it using this:

navigator.serviceWorker.register('/sw.js')
  .then(registration => {})

I'm using .then here because registering a service worker returns a promise. When that promise is resolved, it gives us the registration object, which we will use to subscribe the user and display notifications.

To display a notification, you need to call registration.showNotification(title, options).

So, let's update our code to use the registration object.

const button = document.querySelector('.show-notification-button')

button.addEventListener('click', () => {
  navigator.serviceWorker.register('/sw.js')
    .then((registration) => {
      Notification.requestPermission().then((permissionResult) => {
        if (permissionResult === 'granted') {
          registration.showNotification(
            'My first notification',
            { body: 'More details about this notification' }
          )
        }
      })
    })
})

If you click the "Show Notification" button, you will see the notification displayed like this:

Here's a quick summary of how that worked:

  1. We listened to the button click event.
  2. When the button is clicked, we accessed the registration of the service worker.
  3. Once the promise is resolved and we have the registration object, we request the notification permission.
  4. If the user allowed notifications, we call showNotification on the registration object to show the notification with whatever details we want.

The service worker and the push event

It's not useful to show a notification statically from the client. We should instead show it when we receive a push message. Push messages are sent from a server to the subscribed client.

When the server sends the client a push message, the service worker will trigger an event called push. In that event, we can access the message that was sent by the server to the client to display the notification.

Before building that server app, we can test the push event from the Chrome's dev tools.

To find that, open up the dev tools, go to the "Application" tab, then click on "Service Workers" from the left sidebar, then you should see some information about your registered service worker. Among other things, you should see an input with a button labelled "Push". If you click the "Push" button, it will trigger the push event on this service worker with the text entered in the input.

To test that, you need to add the push event listener to your service worker file, which we named sw.js.

self.addEventListener('push', (event) => {
  const data = event.data
  const message = data.text()
  console.log('message:', message)
})

If you click the push button from the dev tools, you will see the message you entered in the input logged into the browser's console.

But instead of logging it, you can display it in a notification.

self.addEventListener('push', (event) => {
  const data = event.data
  const message = data.text()
  self.registration.showNotification(message)
})

So now we understand what the push event is for and how to display a notification from it. Next, we will see how to subscribe a user to our push notifications, and then how to send notifications from the server app that we will build.

How does your server know what client to send the notification to

The server needs to know information about the client it needs to deliver the notification to. To get that information, you need to subscribe the user first. After you subscribe the user, you will get an object called ‌PushSubscription that contains all the needed information about the client you want to send the notifications to.

The important piece of data in that PushSubscription object is the endpoint field. This field contains a url that is unique to that client. By client I mean the browser that the user used to subscribe to the notifications—remember, a single user can have multiple clients (browsers).

In the server app, we can use that endpoint to send any data we want to the associated client. You will see how in a bit.

How to make sure it's secure

Imagine that someone has stolen that endpoint. Does this mean any one can send any message to the associated client?

Yes if we don't make it secure using the ‌application server keys.

The Application server keys consist of two keys: public and private keys. We use the private key on the server app side to sign the message we want to send, and then use the public key on the client side to unlock it to access its data.

Note that the application server keys are sometimes called the VAPID keys (Voluntary Application Server Identity).

Actually, the usage of these keys is required by some browsers, like Chrome. So, we have to use them to make the notifications work in all browsers.

The nice thing about these keys is that generating and using them is a one-time task. And you can get them either using this site: https://web-push-codelab.glitch.me/ or using this CLI tool.

The important thing is that you should hide the private key from the user. This key is going to be part of your server app, and you can hide it by adding it to your environment variables so you won't push it with your code.

Subscribe the user to your notifications

To subscribe a user, you need to call this function:

registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: YOUR_PUBLIC_KEY
})

This function takes an object with two fields: userVisibleOnly and applicationServerKey.

The applicationServerKey takes the public key you generated above.

The userVisibleOnly is a boolean that indicates whether the received message should be shown to the user with a notification or be a silent push. Some browsers like Chrome does not allow silent pushes because the developer might do malicious things like tracking the user's location without the user knowing. So this field should always be true.

This function returns a promise; when it's resolved, it gives us the PushSubscription object, which we will use as the identifier to the client we want to send push notifications to.

So let's update our code to include the subscription code:

const button = document.querySelector('.show-notification-button')

button.addEventListener('click', () => {
  navigator.serviceWorker.register('/sw.js')
    .then((registration) => {
      Notification.requestPermission().then((permissionResult) => {
        if (permissionResult === 'granted') {
          registration.pushManager.subscribe({
            userVisibleOnly: true,
            applicationServerKey: YOUR_PUBLIC_KEY
          }).then(pushSubscription => {
            console.log('pushSubscription', JSON.stringify(pushSubscription))
          })
        }
      })
    })
})

This code is starting to look messy with all these nested then callbacks. To clean it up, we can update it to use async/await.

const button = document.querySelector('.show-notification-button')

button.addEventListener('click', async () => {
  const registration = await navigator.serviceWorker.register('/sw.js')

  const permissionResult = await Notification.requestPermission()

  if (permissionResult !== 'granted') {
    return
  }

  const pushSubscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: YOUR_PUBLIC_KEY
  })

  console.log('pushSubscription', JSON.stringify(pushSubscription))
})

Next, let's test what we have by clicking "Show Notification". If everything went correctly, then we should see a stringified version of the pushSubscription object logged into the browser's console.

This is the value that you need to store in your server's app for that user. As I explained earlier in this article, it's up to you to decide how it should be associated with the user of your app. You might need to store it in the users table in your database. But for simplicity, I'll just copy the logged string from the console and use it directly in the server app that we'll build next.

Set up a simple Node Express app

You can build the server app using any language and framework you want. For this tutorial, I'm going to stick with JavaScript and build it using Node and Express.

First, create a new folder for the server. For this example I named it web-push-server.

Then quickly generate a package.json file using:

npm init -y

Then install express:

npm install express

And then create a new JavaScript file in the root directory and name it index.js.

Typically, sending a push message would happen after some action on the server. For example, send a notification message to the user when they receive a reply from other users.

For simplicity, I'll just send a notification message when we hit the /send endpoint. This endpoint will accept the message title through a query string called message.

const express = require('express')

const app = express()
const port = 3000

const pushSubscription = JSON.parse(YOUR_PUSH_SUBSCRIPTION_STRING)

app.get('/send', (req, res) => {
  const message = req.query.message || ''
  res.end('Sending message: ' + message)
})

app.listen(port, () => {
  console.log(`web-push-server listening on port ${port}`)
})

Before running the app, replace ‌YOUR_PUSH_SUBSCRIPTION_STRING with the string you logged into the browser's console for the pushSubscription object. Remember, it will be different for each client that subscribes to your notifications.

Now if you run the app (with node index.js) and go to /send?message=Hi, you will see this displayed on the page.

If you see this, then our little server is working and we can move on to the next step to send a push message instead of just showing the message on the page.

Send the notification with the Web Push library

As I mentioned earlier in this article, to send the notification, you need to send a request to the endpoint included in the PushSubscription object. Instead of handling this ourselves, we can use the web push library created for that.

Pick the one for the language you are using in your server from this list: https://github.com/web-push-libs/. For this example, we need the node one.

First, let's install it.

npm install web-push

Then, let's import it and use it in our /send end point.

const express = require('express')
const webpush = require('web-push')

const app = express()
const port = 3000

const pushSubscription = JSON.parse(YOUR_PUSH_SUBSCRIPTION_STRING)

app.get('/send', (req, res) => {
  const message = req.query.message || ''
  webpush.setVapidDetails(
    'mailto:youremail@example.com',
    YOUR_PUBLIC_KEY,
    YOUR_PRIVATE_KEY
  )
  webpush.sendNotification(
    pushSubscription,
    JSON.stringify({ message })
  )
  res.end('Sending message: ' + message)
})

app.listen(port, () => {
  console.log(`web-push-server listening on port ${port}`)
})

I did two things here. First, I called webpush.setVapidDetails to set the VAPID keys. It takes three parameters: your email address, the public key, and the private key—make sure to replace them with your values. I'll explain what this email is for in a little bit.

And then I send the notification using webpush.sendNotification. It takes two parameters: the pushSubscription object, and the message I want to send. In this example, I'm sending it as a stringified JSON so I can include more data into it.

Display the notification message on the push event

If you still have the code for testing the push event in sw.js, then you should see the notification displayed when you hit the /send endpoint on your server. However, it will be displayed as a stringified JSON. To fix that, we need to update the code to accept it as a JSON value (using data.json()), and then take the message value from it.

self.addEventListener('push', (event) => {
  const data = event.data
  const pushMessage = data.json()
  event.waitUntil(
    self.registration.showNotification(pushMessage.message)
  )
})

Another new thing you may have noticed here is the use of event.waitUntil. This function takes a promise to wait for it to resolve—note that self.registration.showNotification returns a promise. We need to wait for that because the browser might terminate the service worker before showing the notification. This can happen because we don't have control over when the browser wakes or terminates the service worker. But with event.waitUntil we can force it to wait and keep the service worker running until we display the notification.

To test it, go to the /send endpoint with some message in the query string. Once it's loaded, you will see the notification displayed on your device.

🎉 Awesome! You now have a working example for adding web push notifications to a site.

There are still two things I haven't talked about in this article. So keep reading!

The push service

An important thing to understand here is that your server doesn't send the push message directly to the user's browser. Instead it sends it to a web service controlled by your browser's vendor called the Push Service.

Each browser vendor uses a different push service. The main responsibility of the push service is to take the push request (which we sent using the web-push library), authenticates it using the public and private keys, and then sends it to the appropriate client.

Note that the push service queues the incoming messages if the user's browser is offline, and then it sends it to the user's browser when it's online.

We as developers don't have control over how the push service works or which one to use in the browser. However, all browsers have a standardized request spec called the web push protocol request. The push messages will work as long as they follow that request spec—using the web-push library saves us from all that work.

In the above code, we had to provide an email when sending the push message. That email is provided to the push service in case it needs to contact the sender.

Web push on mobile devices

The code we wrote in this article should work on mobile devices as well. However, for apple devices with iOS/iPadOS, you should add the site to the home screen and make sure you have display set to standalone or fullscreen in your manifest.json. For more information, read the apple's doc.

For more details

This article covers the basic workflow of adding web push notifications to your site. This is almost all you need to know to use it for any web app you have; however, there are things that I haven't talked about in this article, like customizing the behavior and the UI of the displayed notification. If you need to get into more details, check out this list of in-depth articles on web.dev.

Taha Shashtari

I'm Taha Shashtari, a full-stack web developer. Building for the web is passion. Teaching people how to do that is what I like the most. I like to explore new techniques and tools to help me and others write better code.

Subscribe to get latest updates about my work
©2024 Taha Shashtari. All rights reserved.