Web push notifications are one of the few PWA features that close the gap between a web app and a native one. Done well, they bring users back without an app store install. Done poorly, they trigger a permission prompt the first time a visitor lands on your site, get denied, and burn the opportunity for the lifetime of that browser.
This is a practical walkthrough for adding push notifications to a Progressive Web App in 2026: the service worker setup, the keys you need, the permission-UX patterns that actually convert, the server-side dispatch, and the analytics that tell you whether the whole thing is working. The goal is a system that respects the user's attention, survives browser permission churn, and produces measurable re-engagement.
What web push actually is
Web push is a W3C and IETF specification that lets servers send notifications to a browser even when the user is not on your site. It is built on three primary pieces:
- The Push API in the browser, which receives push events.
- The Notifications API, which renders the notification UI.
- Service workers, which receive push events in the background and call the Notifications API.
The standard is documented across the MDN Web Push API reference and the broader Notifications API documentation. The push transport between your server and the user's browser is handled by a push service operated by the browser vendor (Mozilla, Google, Apple), which means you do not maintain persistent connections to user devices yourself. You POST to the push service, the push service routes to the device.
Support is broad in 2026: Chrome, Firefox, Edge, Safari (iOS 16.4+ with limitations), and most Chromium-based browsers handle web push for installed PWAs. Safari on iOS requires the user to add the PWA to their home screen before subscribing.
The architecture in one diagram
A working web push integration has six moving parts:
- Frontend code that requests permission and subscribes the browser to push.
- A service worker that receives push events and renders notifications.
- A subscription endpoint on your server that stores the subscription object (endpoint URL plus encryption keys) for each user.
- VAPID key pair that authenticates your server to push services.
- A push dispatch service on your backend that sends notifications via the web-push library or equivalent.
- Analytics logging that tracks deliveries, opens, and unsubscribes.
Each piece is small. The complexity lives in the integration between them and in the permission UX.

Photo by Josh Sorenson on Pexels
Step 1: generate VAPID keys
VAPID (Voluntary Application Server Identification) is an IETF standard that lets push services verify which application a notification is coming from. Without VAPID, push services accept anonymous notifications, which makes abuse harder to track.
Generate a key pair using the web-push library:
npx web-push generate-vapid-keys
The output is a public key and a private key. Store the private key as a server secret. The public key gets embedded in your frontend code so the browser can include it when subscribing.
Step 2: register the service worker
The service worker is what receives push events when the browser is in the background. Register it from your main app entry point:
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.register('/sw.js');
return registration;
}
The service worker file at /sw.js is the actual code that runs in the background. The minimal version for push:
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const title = data.title ?? 'New notification';
const options = {
body: data.body,
icon: '/icons/notification-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(clients.openWindow(url));
});
Two things matter here. First, the push event's event.waitUntil() is required, otherwise the browser will kill the service worker before the notification renders. Second, the notificationclick handler is what actually brings the user back to your app , without it, clicking the notification does nothing useful.
Step 3: request permission the right way
The single biggest mistake in web push integrations is calling Notification.requestPermission() on page load. The browser shows a system-level permission prompt, the user has no context for what they're agreeing to, they click "block," and you can never ask again on that browser.
The pattern that works:
- Earn the moment. Don't ask on first visit. Wait until the user has done something that makes notifications obviously valuable: completed a checkout, started watching a thread, set up an alert, finished onboarding.
- Show a soft prompt first. A custom in-app UI that says, in plain language, what notifications are for. "Want a notification when your order ships?" not "Allow notifications?"
- Only call the system prompt after the soft prompt is accepted. This guarantees the user has already opted in conceptually before they see the OS-level prompt.
The double-opt-in pattern shifts permission acceptance rates from the 5%-15% range (cold prompt) to the 40%-60% range (post-context prompt). It is the single highest-leverage change in any web push implementation.
async function subscribeUserToPush(vapidPublicKey) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
}
userVisibleOnly: true is required by Chrome , silent push (push without a visible notification) is not allowed on the open web for spam-prevention reasons.
"The biggest hidden cost of getting web push wrong is not the lost subscriptions on day one. It's that you can't easily recover them. A user who denies permission with no context will deny it forever, and you have to wait for them to clear browser data or switch devices before you get a second chance. The double-opt-in pattern is annoying to build the first time and obvious in hindsight every time after that." - Dennis Traina, founder of 137Foundry
Step 4: store subscriptions on your server
The subscription object returned by pushManager.subscribe() includes the endpoint URL (provided by the push service) and a pair of encryption keys. Store the whole object against the user account.
A reasonable schema:
push_subscriptions
id (uuid)
user_id (fk)
endpoint (text)
p256dh_key (text)
auth_key (text)
user_agent (text)
created_at (timestamp)
last_delivered_at (timestamp)
failure_count (integer)
One user can have multiple subscriptions (one per browser, one per device). Don't deduplicate aggressively. Do clean up when push services return 410 (Gone) on dispatch , that means the subscription is dead and should be deleted.
Step 5: dispatch notifications from the server
Use the web-push library on Node, or the equivalent in your stack. The Python version at pywebpush is solid. The Go version at SherClockHolmes/webpush-go works.
Minimal dispatch in Node:
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:admin@137foundry.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY,
);
async function sendPush(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
return { ok: true };
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) {
// Subscription is gone, delete from database
await deleteSubscription(subscription.endpoint);
}
return { ok: false, status: err.statusCode };
}
}
Real-world dispatch needs queueing. A notification fan-out for 50,000 users is not something you want to do synchronously inside a web request. Use a job queue (BullMQ, Sidekiq, Celery) and batch dispatches at whatever rate the push services accept without backpressure.

Photo by Oktay Köseoğlu on Pexels
Step 6: handle the edge cases
The push subscription lifecycle has more failure modes than most teams plan for.
Subscription expiration. Push services rotate their endpoints periodically. The browser fires a pushsubscriptionchange event when this happens. Your service worker should re-subscribe and POST the new subscription back to your server.
Permission revoked at the OS level. A user can revoke notification permission in their browser settings without touching your app. Your subscription is still in the database; dispatching to it will silently fail or return success without the user seeing anything. Periodically check pushManager.getSubscription() against the database and clean up.
Quiet hours and frequency caps. Even users who opted in will unsubscribe if you ship them six notifications a day. Build server-side frequency caps (one notification per user per hour, max three per day for non-critical) and quiet hours (no notifications between 10pm and 8am in the user's timezone).
Payload size limits. Push services accept payloads up to ~4KB. If you encrypt with VAPID (which you should), real-world limit is closer to 3KB. Keep payloads small; don't try to ship full HTML or large images.
Step 7: measure what matters
Without analytics, you cannot tell whether the system is working. Track at minimum:
- Subscription rate. Of users who saw the soft prompt, what percentage accepted? Of those, what percentage accepted the system prompt?
- Delivery rate. Of notifications dispatched, what percentage came back as successful from the push service? (Note: "successful dispatch" is not the same as "user saw it" , the user could have OS-level Do Not Disturb on.)
- Open rate. Of delivered notifications, what percentage were clicked? Track this in your
notificationclickhandler. - Unsubscribe rate. Track 410 responses from push services as proxy unsubscribes.
- Re-engagement value. What's the conversion rate of users who came back via a notification versus those who didn't?
The point of all this measurement is to know whether push is actually moving the metric you care about, not to chase a vanity number like "subscribers."
Common pitfalls to avoid
In a few years of helping teams ship web push features, we've seen the same five mistakes again and again:
- Cold permission prompts on page load. Always do double-opt-in.
- No
pushsubscriptionchangehandling. Subscriptions silently die and the team wonders why dispatch counts drop over time. - No frequency caps. Marketing pushes go out daily, users unsubscribe, the channel dies within a quarter.
- No fallback for iOS Safari. Users on iOS who haven't installed the PWA don't see notifications at all. Either guide them through home-screen install or accept iOS won't be in scope and route them to email.
- Ignoring 410 responses. Dead subscriptions accumulate, dispatch rates degrade, the queue gets clogged.
We've written about adjacent topics in the 137Foundry web development service overview and at 137foundry.com , push notifications are usually one piece of a broader engagement strategy that includes email, in-app messaging, and re-engagement campaigns. Treating push in isolation tends to produce a system that competes with rather than complements those channels.
For an end-to-end implementation reference, the Mozilla service workers docs walk through the broader API context, and web.dev's PWA guides cover the production-readiness checklist that includes push.
What to ship first
If you're starting from zero, the order that minimizes wasted work:
- Register a service worker. Even if you don't ship push immediately, having an SW registered lets you add offline support, background sync, and push without revisiting the registration code.
- Generate VAPID keys and store them. This takes ten minutes and is required before you can subscribe a single user.
- Build the soft prompt UX. This is the hardest part and the highest-leverage. Get it right before wiring the rest.
- Wire subscription storage server-side. A simple table is fine to start.
- Build dispatch with one notification type. Order shipped, transaction complete, whatever maps naturally to your product.
- Add analytics. You can't iterate on what you can't measure.
- Expand notification types based on data. Only after the first type is proving out its return value.
Web push is one of the cheaper features to ship and one of the more valuable to maintain. The implementation effort is days, not weeks. The ongoing operational discipline (frequency caps, quiet hours, lifecycle handling) is what separates a high-engagement channel from a fast-decaying one. Get the discipline right early and the channel pays back for years; get it wrong and you'll watch the subscription list quietly shrink while you wonder why open rates are dropping. Build it like you mean to keep it.