Skip to main content

PWA Push Notifications: Complete Implementation Guide

Created: September 25, 2018 Larry Qu 6 min read

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

Share this article

Scan to read on mobile

👍 Was this article helpful?