Beginners guide to Web Push Notifications using Service Workers

Push notifications are very common in native mobile application platforms like Android & iOS. They are the most effective way to re-engage users. In this post we will look at how to implement push notifications for the web.

Notification Types

Notifications can be of two types:

  1. Local Notification: Generated by your app itself.
  2. Push Notification: Generated by a server via a push event.

Overview

Components needed for Push Notification:

  1. Service Workers
  2. Notification API: Used to show notification prompts to users
  3. Push API: Used to get push messages from the server

The steps involved:

  1. Ask for permission from the user using the Notification API's requestPermission method
  2. If permission granted:
    • Use service worker to subscribe to a Push Service using Push API
    • Service Worker listens for push events
    • On arrival of push event, service worker shows notification using notification API
  3. If permission denied: Handle this case appropriately

The entire code for this post is available at https://github.com/a7ul/web-push-demo

Step 0: Boilerplate

Create a basic web app structure:

mkdir frontend
cd frontend
git init
touch index.html index.css index.js

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <title>Push Demo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" type="text/css" media="screen" href="index.css" />
    <script src="index.js"></script>
  </head>
  <body>
    <h1>Hello World</h1>
  </body>
</html>

index.js

console.log('js is working')

Run locally with http-server:

npm install -g http-server
http-server

Step 1: Register a Service Worker and Get Permission

Check support first:

const check = () => {
  if (!('serviceWorker' in navigator)) {
    throw new Error('No Service Worker support!')
  }
  if (!('PushManager' in window)) {
    throw new Error('No Push API Support!')
  }
}

const main = () => {
  check()
}

main()

Create service.js:

console.log('Hello from service worker')

Register it in index.js:

const registerServiceWorker = async () => {
    const swRegistration = await navigator.serviceWorker.register('service.js');
    return swRegistration;
}

const main = async () => {
    check();
    const swRegistration = await registerServiceWorker();
}

main();

Debugging Tips for Service Workers:

Request Notification Permission

const requestNotificationPermission = async () => {
    const permission = await window.Notification.requestPermission();
    if(permission !== 'granted'){
        throw new Error('Permission not granted for Notification');
    }
}

const main = async () => {
    check();
    const swRegistration = await registerServiceWorker();
    const permission = await requestNotificationPermission();
}

main();

For production apps, avoid requesting permission immediately. Use a button to trigger it for better UX.

Step 2: Local Notification

const showLocalNotification = (title, body, swRegistration) => {
    const options = {
        body,
    };
    swRegistration.showNotification(title, options);
}

const main = async () => {
    check();
    const swRegistration = await registerServiceWorker();
    const permission = await requestNotificationPermission();
    showLocalNotification('This is title', 'this is the message', swRegistration);
}

main();

Notification Options:

const options = {
  body: "<String>",
  icon: "<URL String>",
  image: "<URL String>",
  badge: "<URL String>",
  vibrate: "<Array of Integers>",
  sound: "<URL String>",
  dir: "<String of 'auto' | 'ltr' | 'rtl'>",
  tag: "<String>",
  data: "<Anything>",
  requireInteraction: "<boolean>",
  renotify: "<Boolean>",
  silent: "<Boolean>",
  actions: "<Array of Strings>",
  timestamp: "<Long>"
}

Main thread vs Service Worker thread:

Step 3: Push Notification

Update index.html to add a button:

<body>
  <h1>Hello World</h1>
  <button id="permission-btn" onclick="main()">Ask Permission</button>
</body>

Subscribe to Push Events

Update service.js:

self.addEventListener('activate', async () => {
  try {
    const options = {}
    const subscription = await self.registration.pushManager.subscribe(options)
    console.log(JSON.stringify(subscription))
  } catch (err) {
    console.log('Error', err)
  }
})

VAPID Keys

Generate VAPID keys for security:

npm install -g web-push
web-push generate-vapid-keys

This generates public and private keys. Keep the private key safe.

Update service.js with VAPID support:

const urlB64ToUint8Array = (base64String) => {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
  const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')
  const rawData = atob(base64)
  const outputArray = new Uint8Array(rawData.length)
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i)
  }
  return outputArray
}

const saveSubscription = async (subscription) => {
  const SERVER_URL = 'http://localhost:4000/save-subscription'
  const response = await fetch(SERVER_URL, {
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  })
  return response.json()
}

self.addEventListener('activate', async () => {
  try {
    const applicationServerKey = urlB64ToUint8Array(
      'BJ5IxJBWdeqFDJTvrZ4wNRu7UY2XigDXjgiUBYEYVXDudxhEs0ReOJRBcBHsPYgZ5dyV8VjyqzbQKS8V7bUAglk'
    )
    const options = { applicationServerKey, userVisibleOnly: true }
    const subscription = await self.registration.pushManager.subscribe(options)
    const response = await saveSubscription(subscription)
    console.log(response)
  } catch (err) {
    console.log('Error', err)
  }
})

Add a push event listener to service.js:

self.addEventListener('push', function (event) {
  if (event.data) {
    console.log('Push event!! ', event.data.text())
  } else {
    console.log('Push event but no data')
  }
})

Backend (NodeJS)

Create a backend project:

mkdir backend
cd backend
npm init
npm install --save express cors body-parser web-push

index.js:

const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const webpush = require('web-push')

const app = express()
app.use(cors())
app.use(bodyParser.json())

const port = 4000

const dummyDb = { subscription: null }

const saveToDatabase = async (subscription) => {
  dummyDb.subscription = subscription
}

app.post('/save-subscription', async (req, res) => {
  const subscription = req.body
  await saveToDatabase(subscription)
  res.json({ message: 'success' })
})

const vapidKeys = {
  publicKey:
    'BJ5IxJBWdeqFDJTvrZ4wNRu7UY2XigDXjgiUBYEYVXDudxhEs0ReOJRBcBHsPYgZ5dyV8VjyqzbQKS8V7bUAglk',
  privateKey: 'ERIZmc5T5uWGeRxedxu92k3HnpVwy_RCnQfgek1x2Y4',
}

webpush.setVapidDetails(
  'mailto:myuserid@email.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
)

const sendNotification = (subscription, dataToSend) => {
  webpush.sendNotification(subscription, dataToSend)
}

app.get('/send-notification', (req, res) => {
  const subscription = dummyDb.subscription
  const message = 'Hello World'
  sendNotification(subscription, message)
  res.json({ message: 'message sent' })
})

app.listen(port, () => console.log(`Example app listening on port ${port}!`))

Showing Push Messages as Notifications

Final service.js:

const urlB64ToUint8Array = (base64String) => {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
  const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')
  const rawData = atob(base64)
  const outputArray = new Uint8Array(rawData.length)
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i)
  }
  return outputArray
}

const saveSubscription = async (subscription) => {
  const SERVER_URL = 'http://localhost:4000/save-subscription'
  const response = await fetch(SERVER_URL, {
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  })
  return response.json()
}

self.addEventListener('activate', async () => {
  try {
    const applicationServerKey = urlB64ToUint8Array(
      'BJ5IxJBWdeqFDJTvrZ4wNRu7UY2XigDXjgiUBYEYVXDudxhEs0ReOJRBcBHsPYgZ5dyV8VjyqzbQKS8V7bUAglk'
    )
    const options = { applicationServerKey, userVisibleOnly: true }
    const subscription = await self.registration.pushManager.subscribe(options)
    const response = await saveSubscription(subscription)
    console.log(response)
  } catch (err) {
    console.log('Error', err)
  }
})

self.addEventListener('push', function (event) {
  if (event.data) {
    console.log('Push event!! ', event.data.text())
    showLocalNotification('Yolo', event.data.text(), self.registration)
  } else {
    console.log('Push event but no data')
  }
})

const showLocalNotification = (title, body, swRegistration) => {
  const options = { body }
  swRegistration.showNotification(title, options)
}

Caveats

Reference article by Rich Harris: Stuff I wish I'd known sooner about service workers

References

This post was ported over from Atul's old blog. Read the original here.