Overview
Progressive Web Apps (PWA) can send push notifications even when the browser is closed, thanks to the Web Push API and Service Workers. This guide covers building a complete push notification system.
Architecture
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ Client โ โโโโบ โ Server โ โโโโบ โ Push โ
โ (Browser) โ โ (Your API) โ โ Service โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
Components
- Service Worker: Handles push events in the browser
- Push API: Browser’s built-in push notification API
- Application Server: Your backend that sends notifications
- Push Service: Third-party service (Firebase Cloud Messaging, etc.)
Step 1: Client-Side Setup
Register Service Worker
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
return registration;
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
Request Push Permission
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return;
}
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notification permission granted');
subscribeUserToPush();
}
}
async function subscribeUserToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
Service Worker (sw.js)
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const title = data.title || 'New Notification';
const options = {
body: data.body || 'You have a new message',
icon: '/images/icon-192x192.png',
badge: '/images/badge-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: data.id || 1
},
actions: [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'view') {
clients.openWindow('/');
}
});
Step 2: VAPID Keys
Generate VAPID keys for authentication:
# Using Node.js
npm install web-push
node -e "console.log(require('web-push').generateVAPIDKeys())"
Output:
{
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
privateKey: 'UUxI4O8-FbRouAf7-7OTt9GH4o-9PN5G9t3cY2xK8fA'
}
Step 3: Server Implementation (Node.js)
// server.js
const webpush = require('web-push');
const express = require('express');
const app = express();
// VAPID keys (generated earlier)
const publicKey = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';
const privateKey = 'UUxI4O8-FbRouAf7-7OTt9GH4o-9PN5G9t3cY2xK8fA';
webpush.setVapidDetails(
'mailto:[email protected]',
publicKey,
privateKey
);
// Store subscriptions in database
const subscriptions = [];
// Subscribe endpoint
app.post('/api/push/subscribe', express.json(), (req, res) => {
const subscription = req.body;
subscriptions.push(subscription);
res.status(201).json({});
});
// Send notification to all subscribers
app.post('/api/push/send', express.json(), async (req, res) => {
const notification = {
title: req.body.title || 'Notification',
body: req.body.body || 'You have a new message',
id: req.body.id || Date.now()
};
const notifications = subscriptions.map(sub =>
webpush.sendNotification(sub, JSON.stringify(notification))
.catch(err => {
console.error('Error sending notification:', err);
if (err.statusCode === 410) {
// Remove expired subscription
const index = subscriptions.indexOf(sub);
subscriptions.splice(index, 1);
}
})
);
await Promise.all(notifications);
res.json({ success: true });
});
app.listen(3000, () => console.log('Server started on port 3000'));
Step 4: Testing
Test with Chrome DevTools
- Open DevTools (F12)
- Go to Application > Service Workers
- Check “Update on reload”
- Test push from your API
Using CLI Tools
# Send a test notification
curl -X POST http://localhost:3000/api/push/send \
-H "Content-Type: application/json" \
-d '{"title": "Test", "body": "Hello World"}'
Security Considerations
- Use HTTPS: Push notifications only work on HTTPS (except localhost)
- Validate Subscriptions: Always validate incoming subscription data
- Rate Limiting: Implement rate limiting to prevent abuse
- VAPID Authentication: Use VAPID keys for sender verification
- Encrypt Payloads: The push service encrypts the notification body
Modern Implementation (2026)
Using VAPID with web-push
// server.js - Modern web-push implementation
const webpush = require('web-push');
// VAPID keys - generate once and keep secret
const vapidKeys = {
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeApAIDgDsdO3ytj31wutf8j6X7Y9y4rj6aN9oxbVKrM16N2w',
privateKey: 'UUxI4O8-FbRouAf7-7OT9lPKaeS96xRBt1r5p9Dk8'
};
webpush.setVapidDetails(
'mailto:[email protected]',
vapidKeys.publicKey,
vapidKeys.privateKey
);
// Store subscriptions in database
const subscriptions = [];
// Send notification
async function sendNotification(subscription, payload) {
try {
await webpush.sendNotification(
subscription,
JSON.stringify(payload)
);
} catch (error) {
if (error.statusCode === 410) {
// Remove expired subscription
removeSubscription(subscription);
}
}
}
Firebase Cloud Messaging (FCM)
// Using FCM for push notifications
const admin = require('firebase-admin');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
async function sendFCM(tokens, title, body) {
const message = {
notification: { title, body },
tokens
};
return admin.messaging().sendEachForMulticast(message);
}
Node.js with Express
// Complete push notification API
const express = require('express');
const webpush = require('web-push');
const app = express();
app.use(express.json());
// In-memory (use database in production)
let subscriptions = [];
// Subscribe endpoint
app.post('/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.push(subscription);
res.status(201).json({});
});
// Send to all
app.post('/broadcast', async (req, res) => {
const { title, body, icon, data } = req.body;
const notifications = subscriptions.map(sub =>
webpush.sendNotification(sub, JSON.stringify({ title, body, icon, data }))
.catch(err => {
if (err.statusCode === 410) return null; // Gone
throw err;
})
);
await Promise.all(notifications);
res.json({ sent: subscriptions.length });
});
app.listen(3000);
Client-Side (Modern)
// service-worker.js (2026)
self.addEventListener('push', event => {
const data = event.data?.json() || {};
const options = {
body: data.body || 'New notification',
icon: data.icon || '/icon-192.png',
badge: '/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/'
},
actions: [
{ action: 'open', title: 'Open' },
{ action: 'close', title: 'Close' }
],
tag: data.tag || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.action === 'open' || !event.action) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
Best Practices
// Notification payload best practices
const notificationPayload = {
title: 'New Message',
body: 'You have a new message from John',
icon: '/icons/notification-icon.png',
badge: '/icons/badge.png',
tag: 'message-notification', // Prevent duplicate
renotify: true, // Always notify
requireInteraction: true, // Keep on screen
data: {
url: '/messages/123',
type: 'new_message'
},
// Rich notification
actions: [
{ action: 'reply', title: 'Reply' },
{ action: 'mark_read', title: 'Mark as Read' }
],
// Image attachment
image: '/images/message-preview.jpg'
};
Conclusion
Building a push notification system for PWAs involves setting up Service Workers, handling browser subscriptions, and managing a server to send notifications. With this foundation, you can create engaging real-time experiences for your users.
Key takeaways:
- Use web-push library for VAPID authentication
- Store subscriptions in a database for production
- Handle expired subscriptions gracefully
- Implement rate limiting to prevent abuse
- Use rich notifications with actions for better engagement
- Test across different browsers and platforms
Comments