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:
- Local Notification: Generated by your app itself.
- Push Notification: Generated by a server via a push event.
Overview
Components needed for Push Notification:
- Service Workers
- Notification API: Used to show notification prompts to users
- Push API: Used to get push messages from the server
The steps involved:
- Ask for permission from the user using the Notification API's
requestPermissionmethod - 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
- 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:
- Chrome DevTools: Application Tab → Service Workers. Enable "update on reload" for automatic reload during development.
- Firefox:
about:debugging#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:
- Main thread: Runs when browsing the webpage
- Service Worker thread: Independent JavaScript thread running in the background, continues even when the page is closed
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