Web Push Notifications
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
- Explain benefit in UI before prompt
- Request on explicit opt-in click
- If denied, show settings link — can't re-prompt
- 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
- Easy unsubscribe in app settings
- Don't send marketing push without explicit consent (GDPR, CAN-SPAM adjacency)
- Rate limit — notification fatigue → uninstall + deny
- No sensitive content in payload body — visible on lock screen
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:
- Prompt install to home screen first
- Request notification permission after install
- Handle
notificationclickto 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
- MDN Push API
- web-push npm library
- web.dev push notifications guide
- Apple Safari web push for PWAs
- RFC 8030 — HTTP Web Push
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 →