Web Push Notifications

WebPWANotificationsFrontend
Share on LinkedIn

Push notifications were the feature PM wanted parity with native. Web Push delivered — with caveats. Safari only fully joined in 16.4 for installed PWAs. Permission prompts blocked on first visit. Payloads capped around 4 KB. But for order updates and chat mentions, web push beats asking users to install an App Store app they'll delete in a week.

Architecture overview

Your server ──► Push service (FCM/Mozilla) ──► Browser ──► Service Worker ──► Notification
     │                                              │
     └── subscription endpoint + keys from client ──┘

Client subscribes once; server stores subscription JSON; server sends HTTPS POST to push endpoint with encrypted payload.

Generate VAPID keys

npx web-push generate-vapid-keys
# Public Key: BNcRd...
# Private Key: 3kT3...

Store private key server-side only. Public key in client subscription flow.

Client subscription flow

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

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

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

  return subscription;
}

Trigger from user action — "Enable notifications" button — never on load.

Service worker push handler

// sw.js
self.addEventListener('push', (event) => {
  let data = { title: 'Update', body: 'You have a new message' };

  if (event.data) {
    try {
      data = event.data.json();
    } catch {
      data.body = event.data.text();
    }
  }

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icon-192.png',
      badge: '/badge-72.png',
      data: { url: data.url || '/' },
      actions: data.actions || [],
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url || '/';
  event.waitUntil(clients.openWindow(url));
});

userVisibleOnly: true required — silent push restricted in most browsers.

Server-side send (Node web-push)

import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPush(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
  } catch (err) {
    if (err.statusCode === 410 || err.statusCode === 404) {
      await removeSubscription(subscription.endpoint); // expired
    }
    throw err;
  }
}

Payload limit ~4 KB — send ID, fetch details on click if needed.

Permission UX that works

  1. Explain benefit in UI before prompt
  2. Request on explicit opt-in click
  3. If denied, show settings link — can't re-prompt
  4. Soft ask: "Would you like notifications?" → Yes → browser prompt

Track funnel: shown → clicked → granted → denied. Optimize copy, not frequency.

Platform differences

Platform Notes
Chrome/Android FCM push service; works in tab and installed PWA
Firefox Mozilla autopush
Safari macOS/iOS 16.4+ Requires Add to Home Screen for iOS web push
Edge Chromium path via FCM

Test matrix on real devices — simulators miss push behavior.

Backend subscription storage

CREATE TABLE push_subscriptions (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id),
  endpoint TEXT NOT NULL UNIQUE,
  p256dh TEXT NOT NULL,
  auth TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

Multiple subscriptions per user (phone + laptop). Prune on 410 Gone responses.

Compliance and respect

Subscription refresh

Browsers rotate push subscriptions periodically. Re-subscribe on service worker update and on pushsubscriptionchange event. Stale subscriptions in DB waste send attempts and skew delivery metrics.

Operational notes

A/B test notification copy through same web push infrastructure — measure click-through without native app experimentation frameworks. Keep variant payloads under size limit.

Segment push topics by notification category — order updates versus marketing — so users can disable promotions without missing transactional alerts.

Test web push on installed PWA versus browser tab on iOS — behavior differs; QA matrix must cover both entry points before marketing announces mobile push support.

Respect quiet hours per user timezone in push scheduler — transactional alerts at 3 AM destroy opt-in rates faster than promotional over-send.

VAPID keys and rotation

Web Push requires VAPID key pair:

const webpush = require("web-push");
webpush.setVapidDetails(
  "mailto:[email protected]",
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

Rotate keys by supporting dual public keys during transition — old subscriptions signed with previous key still validate until users re-subscribe.

Delivery failure handling

HTTP status Action
201 Success
404, 410 Delete subscription from DB
429 Retry with exponential backoff
5xx Retry up to 3 times, then dead-letter
try {
  await webpush.sendNotification(subscription, payload);
} catch (err) {
  if (err.statusCode === 410) {
    await db.deleteSubscription(subscription.endpoint);
  }
}

Track delivery rate per campaign — sudden drops often mean expired VAPID keys or Apple push gateway changes.

iOS PWA push specifics

Safari 16.4+ supports web push for installed PWAs only — not Safari tabs. UX flow:

  1. Prompt install to home screen first
  2. Request notification permission after install
  3. Handle notificationclick to deep-link into app route

Test on real iOS devices — simulator push support is limited.

Pair with progressive web apps offline service worker for complete PWA notification infrastructure.

Common production mistakes

Teams get push notifications wrong in predictable ways:

Production implementations of push notifications fail when staging mirrors production topology poorly, rollback is untested, and on-call runbooks describe the happy path only.

Resources

Frequently asked questions

Do PWAs need a native app to send push notifications?

No. Web Push works through browser push services (FCM for Chrome, APNs for Safari 16.4+ installed PWAs) with a service worker receiving push events. Users must grant notification permission and install or engage with the site per browser rules.

What are VAPID keys used for?

Voluntary Application Server Identification — a public/private key pair identifying your application server to push services. The private key signs push requests; the public key goes in the browser subscription. Required for standards-compliant web push without vendor-specific keys.

Why do users deny notification permission and how do you improve opt-in?

Browsers block permission prompts triggered on page load without user gesture. Explain value first ('Get notified when your order ships'), then request on button click. Sites that prompt immediately see 80%+ denial rates.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →