Skip to main content
โšก Calmops

PWA Push Notifications: Complete Implementation Guide

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
  1. User grants notification permission
  2. Browser subscribes to a push service (Google FCM for Chrome, Mozilla for Firefox)
  3. Browser returns a subscription object with an endpoint URL
  4. Your server stores this subscription
  5. When you want to notify the user, your server sends a message to the endpoint
  6. The push service delivers it to the browser
  7. 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

  1. Open DevTools โ†’ Application โ†’ Service Workers
  2. Click “Push” to simulate a push event
  3. 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

Comments