Back to Articles
Development
Push Notifications
Service Worker
VAPID
Web Development
PWA

Complete Guide: Push Notifications in a Web Application

Architecture, implementation, and debugging — everything you need to know

Base4u Solutions11 min read

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
    code
    mailto:
    address to identify the sender

Generating keys (one-time):

bash
npx 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

code
/sw.js
be in the root? A Service Worker can only control URLs that are "below" it. If the SW is registered at
code
/assets/sw.js
— it cannot control
code
/
or
code
/dashboard
.

This means

code
sw.js
must be in your
code
public/
folder — served as a static file at the root of your domain.


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

code
endpoint
is unique per browser + device + application. If the user opens your app in Chrome and Firefox — there are 2 different endpoints.


Step 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:

  1. •
    code
    web-push
    encrypts the payload using the p256dh/auth keys
  2. •Sends a POST to the endpoint (Google/Mozilla/Apple)
  3. •The push server delivers to the user's browser
  4. •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:

code
New 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 |

code
/sw.js
returns 404 | Navigate manually to
code
yourdomain.com/sw.js
| | Subscription not created | Permission denied | Check browser notification settings | | Push arrives but not displayed | Error in sw.js | Chrome DevTools → Application → Service Workers | | Push not arriving at all | Wrong VAPID / expired subscription (410) | Check function logs | | Works in Chrome, not Safari | Safari requires HTTPS + manifest.json | Add a web app manifest |


Debugging Tools

Chrome DevTools:

  • •
    code
    Application → Service Workers
    — see if SW is registered and active
  • •
    code
    Application → Push Messaging
    — manually send a push for testing
  • •
    code
    Application → Notifications
    — view subscriptions
  • •
    code
    Console
    — SW errors

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:

code
VapidPkHashMismatch
.

This 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

code
PushNotificationSetup
component, and another inside a
code
usePushSubscription
hook. This created a race condition where both attempted to register the device simultaneously — producing registrations that appeared valid but had underlying VAPID key inconsistencies.

Fix: Consolidated all registration logic into a single centralized component (

code
PushNotificationSetup
), eliminating the race condition entirely.


Root 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

code
existingSub.unsubscribe()
step before creating any new subscription. This forces a clean re-registration with the current VAPID key — regardless of what was previously stored.


Root Cause #3: Accumulation of Stale Records in the DB

Over time, the database accumulated duplicate and corrupted subscription records. The

code
sendPushNotification
function was wasting resources attempting to send to dead endpoints.

Fix: 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

code
AlertIndicator
component was polling every 10 seconds and sending a push notification from the frontend whenever there was an unseen alert.

This 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?

TC
Tomer Cohen213d ago

Thanks! Interesting and great opportunity to increase engagement. Thank you so much for the support on my push notification issue!(;