· deepdives  · 7 min read

Unlocking Real-Time Notifications: A Deep Dive into the Push API

Learn how to add real-time push notifications to your PWA. This deep dive covers the Push API, service worker integration, VAPID keys, server-side sending with Node.js, handling payloads, cross-browser considerations, and best practices to keep users happy.

Learn how to add real-time push notifications to your PWA. This deep dive covers the Push API, service worker integration, VAPID keys, server-side sending with Node.js, handling payloads, cross-browser considerations, and best practices to keep users happy.

Outcome-first introduction

By the end of this article you’ll be able to add real-time push notifications to a Progressive Web App (PWA), send encrypted payloads from a server, and handle interactions - all while following best practices that protect users and increase engagement. Read on to implement reliable, respectful push that actually improves the user experience.

Why push notifications matter for PWAs

Push notifications let you reach users even when they don’t have your app open. They increase retention, drive re-engagement, and-when done right-deliver timely value (news, messages, reminders). But push is also easy to abuse. So success comes from technical know-how plus good UX and policy decisions.

This article covers:

  • The roles of the Push API, Notifications API, and Service Workers
  • How to register and subscribe from the client
  • Generating and using VAPID keys
  • Sending notifications from a Node.js server with the web-push library
  • Service worker handling: showNotification, click actions, and payload handling
  • Cross-browser differences and limits
  • Security, privacy, and UX best practices

Conceptual map: how push works (brief)

  • Service Worker runs in the background and listens for push events.
  • The Push Service (browser vendor) holds an endpoint for each subscription.
  • Your server posts a push message to that endpoint (using VAPID for identification and encrypted payloads).
  • The Service Worker receives the push event and usually calls self.registration.showNotification() to present a notification.

Think of the Push Service as the relay; the Service Worker as the messenger; and your server as the sender.

Key APIs and where they fit

Step 1 - Service worker and manifest (PWA prerequisites)

Your site must be served over HTTPS and have a service worker and a manifest. Minimal manifest snippet (manifest.json):

{
  "name": "My PWA",
  "short_name": "PWA",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }
  ]
}

Register the service worker from your main app script:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('/sw.js')
    .then(reg => console.log('SW registered', reg))
    .catch(err => console.error('SW registration failed', err));
}

Step 2 - Ask for permission (UI & timing)

Only request permission in response to a user gesture or after they’ve seen value from your app. Don’t ask on first visit. Short example flow:

async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

If the user grants permission, move on to subscribe to push.

Step 3 - Generate VAPID keys

VAPID (Voluntary Application Server Identification) lets your server identify itself to the push service. Generate a pair and store them securely (private key on server only).

Using the web-push npm tool:

npx web-push generate-vapid-keys --json

It outputs an object with publicKey and privateKey. The publicKey will be used in the browser (applicationServerKey) and the privateKey is used server-side when sending.

You can also generate keys via libraries in other languages. Save them in environment variables or a secure key management system.

Step 4 - Subscribe on the client

Subscribe the user to the Push Service. Use the VAPID public key (base64 URL-safe). Example client code:

// helper to urlBase64 -> Uint8Array
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}

async function subscribeUser(publicVapidKey) {
  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicVapidKey),
  });
  // Send subscription JSON to your server for storage
  await fetch('/api/save-subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(sub),
  });
  return sub;
}

The subscription object contains an endpoint and cryptographic keys (p256dh, auth). Store that JSON on your server associated with the user.

What the subscription looks like (example)

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/abcd...",
  "expirationTime": null,
  "keys": {
    "p256dh": "...",
    "auth": "..."
  }
}

Step 5 - Server: send a push message (Node.js example)

Use the web-push library. It handles encryption and VAPID signing.

// server/sendPush.js
const webpush = require('web-push');

const VAPID_SUBJECT = 'mailto:you@example.com';
webpush.setVapidDetails(
  VAPID_SUBJECT,
  process.env.VAPID_PUBLIC,
  process.env.VAPID_PRIVATE
);

async function sendNotification(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, payload);
    console.log('Push sent');
  } catch (err) {
    console.error('Push error', err);
    // handle unsubscribed endpoints and remove them from DB if 410/404
  }
}

module.exports = { sendNotification };

Call sendNotification(subscription, JSON.stringify({ title: 'Hello', body: 'World' })) from your server when you want to push.

Notes:

  • The web-push library encrypts payloads for you using the subscription keys.
  • A push endpoint may be short-lived or return errors-handle 404/410 by removing subscriptions.

References: https://github.com/web-push-libs/web-push

Step 6 - Handle push in the service worker

In sw.js, listen for push and call showNotification. Use event.waitUntil() so the browser knows you have asynchronous work.

self.addEventListener('push', event => {
  let payload = { title: 'Default title', body: 'Default body' };
  if (event.data) {
    try {
      payload = event.data.json();
    } catch (e) {
      payload = { body: event.data.text() };
    }
  }

  const options = {
    body: payload.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    data: payload.data || {},
    actions: payload.actions || [],
  };

  event.waitUntil(
    self.registration.showNotification(payload.title || 'Notification', options)
  );
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  const urlToOpen =
    event.notification.data && event.notification.data.url
      ? event.notification.data.url
      : '/';

  event.waitUntil(
    clients
      .matchAll({ type: 'window', includeUncontrolled: true })
      .then(windowClients => {
        for (let client of windowClients) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus();
          }
        }
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen);
        }
      })
  );
});

This pattern opens an app window or focuses an existing one when the user clicks the notification.

Payload size and limits

Push payload sizes are limited. Chrome allows up to ~4KB for the payload. If you need larger data, send a lightweight push that tells the client to fetch the full content from your server (data-only push). Avoid large payloads.

Cross-browser notes and Safari

Reliability and error handling

  • Handle server responses. If the push endpoint returns 404 or 410, unsubscribe the subscription on your side and remove it from your DB.
  • Retries: implement exponential backoff for transient errors.
  • Monitor push success rates and user interaction metrics.

UX & policy best practices

  • Ask for permission at the right time - after the user understands value.
  • Provide settings to opt out and control frequency/topics.
  • Use clear, concise notification text and actionable buttons.
  • Avoid sending too many notifications. One thoughtful notification is better than multiple annoying ones.
  • When notifications are important (e.g., security alerts), mark them as high priority sparingly. Some browsers expose urgency hints.

Security and privacy

  • Serve over HTTPS. Push and service workers require secure contexts.
  • Keep the VAPID private key on the server only.
  • Do not leak sensitive data in notifications that might appear on lock screens.
  • Respect Notification permission and don’t re-prompt after denial; provide in-app controls instead.

Testing tips

  • Use Chrome DevTools > Application > Service Workers and Push Messaging to test push messages.
  • For development, you can use web-push to send test messages. Example:
npx web-push send-notification --endpoint='<endpoint>' --key='<p256dh>' --auth='<auth>' --vapid-subject='mailto:you@example.com' --vapid-pubkey='<PUBLIC>' --vapid-pvtkey='<PRIVATE>' --payload '{"title":"Test","body":"Hello"}'
  • Test on real devices and different browsers. Mobile behavior, battery optimizations, and OS notification settings affect delivery.

Advanced topics (quick overview)

  • Topic-based subscriptions: store topic preferences server-side, and send to filtered subscriptions.
  • Silent pushes / data-only pushes: useful for background sync, but many browsers limit or throttle them.
  • Analytics: track delivered vs. clicked notifications and use that to tune message content and timing.

Example end-to-end flow recap (short)

  1. Generate VAPID keys on server, keep private key safe.
  2. Register service worker and manifest in the PWA.
  3. Request Notification permission at an appropriate time.
  4. Call pushManager.subscribe() with the VAPID public key and send subscription JSON to your server.
  5. Server stores subscription and calls web-push to send messages (encrypted payloads).
  6. Service worker receives push and displays a notification. Handle notificationclick to navigate users.

Final thoughts

Push notifications give your PWA a powerful channel. Implement them properly and they become a warm bridge back to your product. Implement them poorly and you lose users’ trust. Choose technical correctness, respectful UX, and careful testing-and you’ll have a feature that reliably delivers value to your users.

Back to Blog

Related Posts

View All Posts »
Unlocking the Power of Periodic Background Sync: A Deep Dive

Unlocking the Power of Periodic Background Sync: A Deep Dive

A practical, example-driven guide to the Periodic Background Sync API - how it works, when to use it, code samples, browser support, security considerations, and production-ready best practices for keeping your web app data fresh while respecting battery and privacy.