Skip to main content
โšก Calmops

Building a Push Notification Server for Progressive Web Apps

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

Comments