Web Push Notifications

WebPWANotificationsFrontend
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

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.

VAPID rotation without losing every subscriber

Rotating VAPID keys invalidates all push subscriptions. Stage rotation: accept both keys server-side during overlap, prompt users to re-subscribe on next visit, then retire the old key after 30 days. Track subscription.age and last_successful_push — stale endpoints should be pruned before rotation to shrink blast radius.

Action buttons and collapse tags

Use notification tag to replace superseded alerts (one severe weather warning, not six). Action buttons work on Android Chrome; iOS installed PWAs have limited action support — design flows that work with tap-only on Apple hardware. Deep-link notificationclick through a URL router that restores auth session before showing protected content.

Permission re-prompt after denial

Browser blocks re-prompt — show settings instructions with deep link on Android intent to site settings. Track denied users separately; do not waste UI space on impossible prompt.

Push payload localization

Server sends locale-specific title/body — SW should not machine-translate; pre-localized payload avoids SW complexity and wrong-language incidents.

Production rollout notes

Measure push opt-in funnel separately on Android installed PWA vs iOS Home Screen PWA vs desktop — conversion rates differ by an order of magnitude. Campaign planners who expect iOS tab users to receive push will miss SLA. Document platform matrix in product requirements before marketing promises realtime mobile alerts from web-only experience.

Segment-specific push copy

Push copy that works for retention nudges fails for transactional alerts — segment templates by message class with separate opt-in where regulations require. Mixing marketing tone in shipping notifications increases unsubscribe rate on transactional channel users conflate with spam.

Web push in enterprise SSO environments

Session cookies expiring while push subscription remains cause deep links to login wall — notificationclick handler should route through silent refresh or magic link flow documented for enterprise IdP timeouts. Test push click after eight-hour idle session on corporate laptop.

Closing operational guidance

Audit notification permission timing against Core Web Vitals — permission banner competing with LCP hero hurts both metrics. Defer push education until after main content interactive when INP budget allows. Ship changes behind feature flags, measure before and after on real traffic, and keep rollback one deploy revert away. Ship changes behind feature flags, measure before and after on real traffic, and keep rollback one deploy revert away.

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 →