Introduction
Push notifications let your web app send messages to users even when they’re not actively using the site โ just like native mobile apps. They work through a combination of the Push API, Notification API, and Service Workers.
How Web Push Works
Your Server โ Push Service (Google FCM / Mozilla) โ User's Browser โ Service Worker โ Notification
- User grants notification permission
- Browser subscribes to a push service (Google FCM for Chrome, Mozilla for Firefox)
- Browser returns a subscription object with an endpoint URL
- Your server stores this subscription
- When you want to notify the user, your server sends a message to the endpoint
- The push service delivers it to the browser
- The Service Worker receives it and shows the notification
Step 1: Request Notification Permission
// Check current permission status
console.log(Notification.permission);
// => "default" | "granted" | "denied"
// Request permission
async function requestNotificationPermission() {
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
console.log('Notifications blocked by user');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
Best practice: Only request permission after the user has engaged with your app and understands why they’d want notifications. Don’t ask immediately on page load.
Step 2: Generate VAPID Keys
VAPID (Voluntary Application Server Identification) keys authenticate your server with the push service.
# Install web-push CLI
npm install -g web-push
# Generate VAPID keys
web-push generate-vapid-keys
Output:
Public Key: BNBnYTHlMDZRGlNOW3ulmxoNIua94THU9QCFT0nlztuZaFU...
Private Key: abc123...
Store the private key securely on your server. The public key goes in your frontend code.
Step 3: Register Service Worker and Subscribe
// main.js
const VAPID_PUBLIC_KEY = 'BNBnYTHlMDZRGlNOW3ulmxoNIua94THU9QCFT0nlztuZaFU...';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
async function subscribeToPush() {
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Check for existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // required: all pushes must show a notification
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),
credentials: 'include'
});
console.log('Push subscription:', subscription);
return subscription;
}
// Initialize
async function init() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported');
return;
}
const granted = await requestNotificationPermission();
if (granted) {
await subscribeToPush();
}
}
init();
The Subscription Object
{
"endpoint": "https://fcm.googleapis.com/fcm/send/da6nAuGjSxA:APA91bGT...",
"expirationTime": null,
"keys": {
"p256dh": "BNBnYTHlMDZRGlNOW3ulmxoNIua94THU9QCFT0nlztuZaFU...",
"auth": "Krh9e8ATlfyEKKOqeGIO8A"
}
}
Step 4: Service Worker โ Receive and Show Notifications
// sw.js
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || 'New notification',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
image: data.image,
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
dateOfArrival: Date.now()
},
actions: [
{ action: 'view', title: 'View', icon: '/icons/view.png' },
{ action: 'dismiss', title: 'Dismiss', icon: '/icons/dismiss.png' }
]
};
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'dismiss') return;
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
// Focus existing window if open
for (const client of windowClients) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
Step 5: Server-Side โ Send Push Notifications
Node.js with web-push
npm install web-push
// push-server.js
const webpush = require('web-push');
// Configure VAPID
webpush.setVapidDetails(
'mailto:[email protected]', // contact email
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// Store subscriptions (use a database in production)
const subscriptions = new Map();
// Save subscription endpoint
app.post('/api/push/subscribe', (req, res) => {
const subscription = req.body;
const userId = req.session.userId;
subscriptions.set(userId, subscription);
res.status(201).json({ message: 'Subscribed' });
});
// Send notification to a user
async function sendNotification(userId, payload) {
const subscription = subscriptions.get(userId);
if (!subscription) return;
try {
await webpush.sendNotification(
subscription,
JSON.stringify(payload)
);
console.log('Notification sent to user:', userId);
} catch (error) {
if (error.statusCode === 410) {
// Subscription expired โ remove it
subscriptions.delete(userId);
}
console.error('Push failed:', error);
}
}
// Example: send notification when something happens
app.post('/api/orders', async (req, res) => {
const order = await createOrder(req.body);
// Notify the user
await sendNotification(req.session.userId, {
title: 'Order Confirmed!',
body: `Your order #${order.id} has been placed.`,
url: `/orders/${order.id}`,
image: '/images/order-confirmed.png'
});
res.json(order);
});
Broadcast to All Subscribers
async function broadcastNotification(payload) {
const promises = [];
for (const [userId, subscription] of subscriptions) {
promises.push(
webpush.sendNotification(subscription, JSON.stringify(payload))
.catch((err) => {
if (err.statusCode === 410) {
subscriptions.delete(userId);
}
})
);
}
await Promise.allSettled(promises);
console.log(`Broadcast sent to ${subscriptions.size} subscribers`);
}
Handling Subscription Expiry
Subscriptions can expire or become invalid. Always handle errors:
// Error codes
// 404: Subscription not found (expired)
// 410: Subscription gone (user unsubscribed)
// 429: Too many requests (rate limited)
async function sendWithRetry(subscription, payload, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
return true;
} catch (error) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription invalid โ remove from database
await db.subscriptions.delete({ endpoint: subscription.endpoint });
return false;
}
if (attempt === maxRetries) throw error;
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
Unsubscribing
async function unsubscribeFromPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
// Notify server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
credentials: 'include'
});
}
}
Testing Push Notifications
Chrome DevTools
- Open DevTools โ Application โ Service Workers
- Click “Push” to simulate a push event
- Enter JSON payload and click “Push”
Command Line
# Send a test notification using web-push CLI
web-push send-notification \
--endpoint="https://fcm.googleapis.com/fcm/send/..." \
--key="p256dh-key" \
--auth="auth-key" \
--vapid-subject="mailto:[email protected]" \
--vapid-pubkey="your-public-key" \
--vapid-pvtkey="your-private-key" \
--payload='{"title":"Test","body":"Hello!"}'
Browser Support and Limitations
| Browser | Support | Notes |
|---|---|---|
| Chrome | Full | Uses Google FCM |
| Firefox | Full | Uses Mozilla push service |
| Edge | Full | Uses Microsoft push service |
| Safari (macOS 13+) | Partial | Requires user gesture, no badge |
| iOS Safari | iOS 16.4+ | Must be installed as PWA |
| Samsung Internet | Full | Uses Google FCM |
Important: Push notifications require internet connectivity to the push service (Google FCM for Chrome). This means they may not work in regions where Google services are blocked.
Resources
- MDN: Push API
- web.dev: Push Notifications
- web-push npm package
- Google Codelab: Push Notifications
- Push Companion (test tool)
Comments