Complete Guide: Push Notifications in a Web Application
Architecture, implementation, and debugging — everything you need to know
What Is a Push Notification?
A Push Notification is a mechanism that allows a server to "push" a message to the user's browser — even when the application is completely closed.
The browser acts as an intermediary: it maintains an open connection to Google/Apple/Mozilla's push servers (depending on the browser) and receives messages through them.
Architecture — 4 Players
Before writing a single line of code, it's worth understanding who's involved:
[1] The Browser (the user) — creates a subscription (a unique address + encryption keys)
[2] The Push Server — Google FCM, Mozilla, Safari APNs — depends on the browser. This is the infrastructure layer you don't control.
[3] sw.js (Service Worker) — JavaScript code that runs in the background even when the app is closed, listening for push events.
[4] Your Server — sends the push via the Web Push protocol + VAPID.
The flow: your server sends to the push infrastructure → the push infrastructure delivers to the browser → the Service Worker wakes up and displays the notification.
VAPID — Why Do We Need Keys?
VAPID (Voluntary Application Server Identification) is a security mechanism that proves to the push server that we are the owners of the application.
- •VAPID_PUBLIC_KEY — sent to the browser during subscription
- •VAPID_PRIVATE_KEY — stored only on the server, signs every message
- •VAPID_SUBJECT — a address to identify the sendercode
mailto:
Generating keys (one-time):
bashnpx web-push generate-vapid-keys
Store these somewhere safe — you'll need them in your server environment variables.
Full Flow — Step by Step
Step 1 — Register the Service Worker
js// Runs on every app load const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none", });
Why must /sw.js
/assets/sw.js//dashboardThis means
sw.jspublic/Step 2 — Create a Subscription (Request Permission)
js// Request permission from the user const permission = await Notification.requestPermission(); // Returns: "granted" | "denied" | "default" // Create subscription — browser generates a unique endpoint + encryption keys const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // Required — ensures every push shows a notification applicationServerKey: vapidPublicKey, }); // The subscription object looks like this: { endpoint: "https://fcm.googleapis.com/fcm/send/UNIQUE_TOKEN_HERE", keys: { p256dh: "BNbF...EQ==", // Public encryption key auth: "tBHI...==", // Authentication key } }
The
endpointStep 3 — Save the Subscription on the Server
js// registerPushSubscription.js (Backend Function) // Store in a PushNotificationSubscription entity: { user_email: "user@example.com", endpoint: "https://fcm.googleapis.com/...", p256dh: "BNbF...EQ==", auth: "tBHI...==", is_active: true }
This is essential — without it, the server has no idea where to send notifications.
Step 4 — Send a Push from the Server
js// sendPushNotification.js (Backend Function) import webpush from 'npm:web-push@3.6.7'; webpush.setVapidDetails( "mailto:app@example.com", // VAPID_SUBJECT PUBLIC_KEY, // VAPID_PUBLIC_KEY PRIVATE_KEY // VAPID_PRIVATE_KEY ); // Send to each subscription: await webpush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, JSON.stringify({ title: "New Notification", body: "Notification content here" }) );
What happens behind the scenes:
- •encrypts the payload using the p256dh/auth keyscode
web-push - •Sends a POST to the endpoint (Google/Mozilla/Apple)
- •The push server delivers to the user's browser
- •The browser wakes up the Service Worker
Step 5 — Service Worker Displays the Notification
js// public/sw.js — runs in background even when app is closed self.addEventListener("push", (event) => { const data = event.data.json(); // Receives payload from server event.waitUntil( self.registration.showNotification(data.title, { body: data.body, requireInteraction: true, // Notification stays until user clicks }) ); // event.waitUntil is important! Prevents the browser from // killing the SW before the notification is displayed. }); self.addEventListener("notificationclick", (event) => { event.notification.close(); // Opens or refocuses the application event.waitUntil(clients.openWindow("/")); });
Step 6 — Connecting Everything with Automation
The real power comes when you combine this with entity-based automations:
codeNew record created in DB ↓ Automation triggered (entity trigger on create) ↓ onRecordCreated() called ↓ sendPushNotification({ title, body, target_users }) ↓ web-push sends to all endpoints from DB ↓ sw.js displays notification
This means notifications happen automatically — no manual triggering required.
Common Issues and How to Diagnose Them
| Problem | Cause | Check | |---|---|---| | SW not registering |
/sw.jsyourdomain.com/sw.jsDebugging Tools
Chrome DevTools:
- •— see if SW is registered and activecode
Application → Service Workers - •— manually send a push for testingcode
Application → Push Messaging - •— view subscriptionscode
Application → Notifications - •— SW errorscode
Console
Firefox:
- •code
about:debugging → Service Workers
Important Limitations
- •iOS Safari — requires iOS 16.4+ and an installed PWA (Add to Home Screen)
- •HTTPS is required — Push does not work over HTTP (except on localhost)
- •Subscriptions expire — if a browser hasn't been opened for a long time, the endpoint may become invalid. A 410 response means you should delete it from the DB
- •sw.js must be a static file — it cannot be a server-side function
Real-World Bugs and How We Fixed Them
A summary of production issues we encountered and resolved — particularly around iOS.
The Core Problem: VapidPkHashMismatch on iOS
Push notifications were not being delivered to iOS devices (in PWA mode), with an error:
VapidPkHashMismatchThis error means the subscription's endpoint was registered with one VAPID public key, but the send request was made with a different (updated) key. Apple is significantly stricter about this than other browsers — a mismatch is a hard rejection.
Root Cause #1: Duplicate Registration Mechanisms
There were two separate registration mechanisms running in parallel: one inside a
PushNotificationSetupusePushSubscriptionFix: Consolidated all registration logic into a single centralized component (
PushNotificationSetupRoot Cause #2: Stale VAPID Keys in Apple's Servers
Some subscriptions had been created with old VAPID keys. Even after updating the keys on the server, Apple's infrastructure kept those subscriptions tied to the original keys. Sending with a new key caused an immediate rejection.
Fix: Added an explicit
existingSub.unsubscribe()Root Cause #3: Accumulation of Stale Records in the DB
Over time, the database accumulated duplicate and corrupted subscription records. The
sendPushNotificationFix: Updated the registration logic so that every time a user registers (or re-authenticates), the system first deletes all their existing subscription records, then creates a single fresh one. This prevents stale record accumulation.
Fix #4: Manual Cleanup
As part of the debugging process, a one-time manual cleanup of "stuck" subscriptions was performed directly in the database — subscriptions that weren't caught by the automatic cleanup logic.
Root Cause #5: Push Being Triggered from the Frontend
An
AlertIndicatorThis is a fundamental architectural mistake. The frontend should never be responsible for triggering push notifications — it has no reliable lifecycle, it runs per-tab, and it results in duplicate sends.
Fix: Removed all push-sending logic from the frontend entirely. Push notifications are now triggered exclusively from the backend via an entity automation — fired once, at the moment a new alert is created in the database. The frontend's only job is to display the notification after it arrives.
Key Takeaway
In distributed systems like Push Notifications — where client, server, and third-party infrastructure (Apple/Google) all interact — robust and consistent registration/unregistration logic is non-negotiable.
The system must always clean up after itself and guarantee that active subscriptions use the correct, current keys. This is especially critical on iOS, which enforces VAPID key consistency far more strictly than other platforms.
Summary
Push notifications are one of the most powerful engagement tools available to web apps — but they require understanding the full stack: browser APIs, service workers, VAPID authentication, and server-side delivery.
The good news: once you've set it up correctly, the architecture is solid and reliable. The patterns described here are production-ready and have been battle-tested across real applications.
Questions are welcome.
Comments(1)
Want to join the conversation?
Thanks! Interesting and great opportunity to increase engagement. Thank you so much for the support on my push notification issue!(;