Skip to main content

Building a Push Notification Server for Progressive Web Apps

Created: September 25, 2018 Larry Qu 5 min read

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

  1. Service Worker: Handles push events in the browser
  2. Push API: Browser’s built-in push notification API
  3. Application Server: Your backend that sends notifications
  4. 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

  1. Open DevTools (F12)
  2. Go to Application > Service Workers
  3. Check “Update on reload”
  4. 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

  1. Use HTTPS: Push notifications only work on HTTPS (except localhost)
  2. Validate Subscriptions: Always validate incoming subscription data
  3. Rate Limiting: Implement rate limiting to prevent abuse
  4. VAPID Authentication: Use VAPID keys for sender verification
  5. 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

Resources

Comments

Share this article

Scan to read on mobile

👍 Was this article helpful?