The Push Architecture#

Push notifications on the web involve three actors and two separate APIs:

┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Your Server  │ ──► │  Push Service     │ ──► │  Browser +   │
│  (backend)    │     │  (vendor-operated)│     │  Service Worker│
└──────────────┘     └──────────────────┘     └──────────────┘
      │                     │                        │
  Web Push Protocol     Vendor endpoint          push event
  (RFC 8030)            (FCM, Mozilla, etc.)     in SW
ComponentRole
Notification APIDisplays notifications to the user (can be used without push)
Push APIAllows the server to send messages to the browser, even when the page is closed
Push ServiceVendor-operated server (FCM for Chrome, autopush for Firefox) that queues and delivers messages
VAPIDVoluntary Application Server Identification — authenticates your server to the push service

The Notification API#

The Notification API works independently of push — you can show notifications from page JavaScript without involving a server:

// Request permission
const permission = await Notification.requestPermission();
// Returns: 'granted', 'denied', or 'default'

if (permission === 'granted') {
  new Notification('Hello!', {
    body: 'This is a local notification',
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
  });
}

Notification Options#

OptionTypePurpose
bodystringMain notification text
iconURLLarge icon displayed alongside the notification
badgeURLSmall monochrome icon (Android status bar)
imageURLLarge image displayed in the notification body
tagstringIdentifier for replacing/grouping notifications
renotifybooleanVibrate/sound again when replacing a tagged notification
actionsarrayUp to two action buttons
dataanyCustom data accessible in the notification click handler
silentbooleanSuppress sound and vibration
requireInteractionbooleanNotification persists until user interacts (desktop)

Service Worker Notifications#

For push-triggered notifications, use self.registration.showNotification() inside the service worker:

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};

  event.waitUntil(
    self.registration.showNotification(data.title || 'New Update', {
      body: data.body || 'You have a new notification',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      tag: data.tag || 'default',
      data: { url: data.url || '/' },
      actions: [
        { action: 'open', title: 'Open' },
        { action: 'dismiss', title: 'Dismiss' },
      ],
    })
  );
});

Handling Notification Clicks#

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

  const targetUrl = event.notification.data?.url || '/';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
      // Focus an existing tab if one is open
      for (const client of windowClients) {
        if (client.url === targetUrl && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise open a new tab
      return clients.openWindow(targetUrl);
    })
  );
});

VAPID Keys#

VAPID (Voluntary Application Server Identification) uses a public/private key pair to authenticate your server to the push service. This prevents other servers from sending notifications to your subscribers.

Generating VAPID Keys#

npx web-push generate-vapid-keys

Output:

Public Key:  BNbxGYNMhEIi3hYH0kfJft1_6Gzlhm...
Private Key: 3KzvKMdXaFGkYLaPQMrwBP5pnGkD3R...

Store the private key securely on your server. The public key is sent to the browser during subscription.


Subscription Management#

Subscribing a User#

async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  // Check existing subscription
  let subscription = await registration.pushManager.getSubscription();
  if (subscription) return subscription;

  // Convert VAPID public key from base64 to Uint8Array
  const vapidPublicKey = 'BNbxGYNMhEIi3hYH0kfJft1_6Gzlhm...';
  const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

  subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // Required: promise to show a notification for every push
    applicationServerKey: convertedKey,
  });

  // Send subscription to your backend
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

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

The Subscription Object#

The PushSubscription returned by subscribe() looks like this:

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/fD1Jc...",
  "expirationTime": null,
  "keys": {
    "p256dh": "BLQELIDm-6b2Iwh...",
    "auth": "4vQK-SvR..."
  }
}
FieldPurpose
endpointThe push service URL unique to this subscription
keys.p256dhPublic encryption key for message payload
keys.authAuthentication secret

Store these on your server, associated with the user.

Unsubscribing#

async function unsubscribe() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.getSubscription();

  if (subscription) {
    await subscription.unsubscribe();
    // Notify your backend to remove the subscription
    await fetch('/api/push/unsubscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ endpoint: subscription.endpoint }),
    });
  }
}

Server-Side Push#

Using the web-push Library (Node.js)#

npm install web-push
import webPush from 'web-push';

webPush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webPush.sendNotification(
      subscription,
      JSON.stringify(payload),
      { TTL: 60 * 60 } // Time-to-live in seconds
    );
    console.log('Push sent successfully');
  } catch (error) {
    if (error.statusCode === 410 || error.statusCode === 404) {
      // Subscription expired or invalid — remove from database
      await removeSubscription(subscription.endpoint);
    } else {
      console.error('Push failed:', error);
    }
  }
}

// Usage
await sendPushNotification(userSubscription, {
  title: 'New Message',
  body: 'You have a new message from Alice',
  url: '/messages/123',
  tag: 'message-123',
});

Push Service Response Codes#

StatusMeaningAction
201Message acceptedSuccess
404Subscription not foundRemove from database
410Subscription expiredRemove from database
413Payload too largeMax ~4 KB encrypted
429Too many requestsRetry with backoff

Permission UX Best Practices#

The notification permission prompt is one-shot in many browsers — if the user dismisses or denies it, you cannot ask again easily. Poor timing destroys your opt-in rate.

What NOT to Do#

  • ❌ Request permission on page load before the user has engaged.
  • ❌ Show the browser prompt without context or explanation.
  • ❌ Use dark patterns (“Allow notifications to continue reading”).
function showPermissionDialog() {
  const dialog = document.getElementById('push-dialog');
  dialog.innerHTML = `
    <h3>Stay Updated</h3>
    <p>Get notified when your order ships or when items go on sale.</p>
    <button id="push-accept">Enable Notifications</button>
    <button id="push-decline">Not Now</button>
  `;
  dialog.hidden = false;

  document.getElementById('push-accept').addEventListener('click', async () => {
    dialog.hidden = true;
    const permission = await Notification.requestPermission();
    if (permission === 'granted') {
      await subscribeToPush();
    }
  });

  document.getElementById('push-decline').addEventListener('click', () => {
    dialog.hidden = true;
    localStorage.setItem('push-declined', Date.now().toString());
  });
}

// Only show after meaningful engagement
function maybePromptForPush() {
  if (Notification.permission !== 'default') return; // Already decided
  const declined = localStorage.getItem('push-declined');
  if (declined && Date.now() - parseInt(declined) < 30 * 24 * 60 * 60 * 1000) return; // Wait 30 days

  showPermissionDialog();
}

Timing Recommendations#

TriggerWhy It Works
After a purchase completesUser wants order updates
After following a topicUser wants content updates
After using the app 3+ timesDemonstrated engagement
When the user clicks a “notify me” buttonExplicit intent

Key Takeaways#

  • Push notifications involve two APIs: the Notification API (display) and the Push API (server-to-browser delivery), connected through vendor-operated push services.
  • VAPID keys authenticate your server to the push service — the public key is shared with the browser, the private key stays on the server.
  • The PushSubscription object (endpoint + encryption keys) must be stored on your server and removed when the push service returns 404 or 410.
  • Always handle notification clicks by focusing an existing tab or opening a new one — an unhandled click is a missed engagement opportunity.
  • Permission UX is critical: use a two-step dialog that explains value before triggering the browser prompt, and time it to a moment of demonstrated user intent.
  • Server-side push payloads are encrypted and limited to approximately 4 KB — send a minimal data object and let the service worker construct the full notification.