#React#TypeScript

Web Push Notifications: From Subscription to Delivery

Want to send push notifications to web users without an app? The Web Push API, Service Workers, and push services make it possible. This article explains the full flow (subscription → server push → SW display), VAPID keys, notification permissions and UX, and support in iOS Safari 16.4+.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Anyone who has worked on user retention has probably wrestled with the same question: how can you reach users after they leave your website? Email is too slow, SMS is too expensive, IM requires an account system, and app notifications require users to install an app first. For a long time, the Web had no compelling answer, until Web Push matured.

The most important change over the past two years is that iOS 16.4+ finally supports Web Push. This means the same Push API + Service Worker code can receive notifications on desktop Chrome, Edge, Firefox, and Safari, as well as on iPhones. Safari used to be Web Push's biggest blind spot; today, it has filled the final gap. This article explains the entire path from subscription to delivery, with code written to a standard you can actually run.

1. The Value of Web Push: Like an App, Without Installing One

The core capability of Web Push can be summed up in one sentence: even after users close your website, or even close the browser on desktop, you can still deliver a message to their screen. This is fundamentally different from showing a toast on the page. A toast requires the page to remain open; Web Push does not.

It relies on three underlying components:

  1. Push Service: Each browser vendor operates a relay service. Chrome uses FCM, Firefox uses Mozilla's own service, and Safari uses APNs. Your server sends the message to this service, which delivers it to the corresponding device.
  2. Service Worker: A JavaScript script that stays available in the browser background and is awakened on demand when a message arrives so it can display a notification.
  3. Notification API: The capability that actually renders the notification in the system notification center.

Together, these components give the Web a notification channel almost equivalent to that of a native app, without requiring users to install anything. Authorization, subscription, and receipt all happen inside the browser. For utility websites, content communities, and admin systems, this is a powerful retention tool.

2. The Complete Flow: Seven Steps from Subscription to Delivery

Many people can write showNotification but cannot explain the full flow. Here it is in seven steps, following the data from end to end:

  1. User authorization: Notification.requestPermission() opens a prompt, and the user clicks "Allow."
  2. Register the Service Worker: The page calls navigator.serviceWorker.register('sw.js') and receives a registration.
  3. Subscribe to Push: Call pushManager.subscribe() through the registration to obtain a PushSubscription, which contains an endpoint URL and a pair of keys.
  4. Upload the subscription: Send the subscription to your backend and store it in the database, one record per user.
  5. Server-side delivery: The backend signs the request with the VAPID private key, encrypts the payload using the subscription's public key, and sends a POST request to the endpoint URL.
  6. Push service relay: The browser vendor's Push service receives the request, verifies the signature and encrypted message, and delivers it to the browser on the corresponding device.
  7. The Service Worker is awakened by the push event, calls showNotification to render the notification, and handles clicks through notificationclick.

Commit this flow to memory. Every concept below fills a role in one of these steps: VAPID provides the identity signature in step 5, the subscription is the handshake result from steps 3 and 4, and the Service Worker is the receiver in step 7.

3. VAPID: Your Identity in the Push Ecosystem

VAPID (Voluntary Application Server Identification) is the most confusing concept in this flow, but its purpose is straightforward: it lets the Push service know that the notification came from your application, preventing someone else from impersonating you and sending messages to your users.

It uses a public-private key pair (ECDSA P-256):

  • Private key: Stays on your server, is never exposed, and signs every push request.
  • Public key: Passed to the browser as applicationServerKey during subscription. The browser gives it to the Push service, which later uses it to verify your signatures.

Generating the pair is a one-time operation. The web-push library does it in one line:

js
// Run this only once, store the result in environment variables, and reuse it
import webpush from 'web-push';

const vapidKeys = webpush.generateVAPIDKeys();
console.log(vapidKeys);
// { publicKey: 'BMxx...base64url', privateKey: 'DhM...' }

// Put these values in VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY respectively

Remember: the public key goes to the frontend for subscription, while the private key stays on the backend. If both are compromised, someone else can send notifications as your application. Generate a new pair and require users to subscribe again.

4. Subscription: Permission + PushManager.subscribe()

Subscription has two smaller steps that must happen in order: obtain Notification permission first, then call subscribe.

ts
// Client code - note: run inside a 'use client' boundary and trigger from a user gesture such as a button click
async function subscribeUser() {
  // 1. Permission
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    // The user rejected or ignored it - do not push; a brief message is enough
    return null;
  }

  // 2. Register the Service Worker
  const reg = await navigator.serviceWorker.ready;

  // 3. Subscribe to Push - applicationServerKey is the VAPID public key
  const subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true, // Must be true: every push must display a notification
    applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  });

  // 4. Send the subscription to the backend for storage
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

Several key points need to be clear:

  • userVisibleOnly: true is mandatory in every browser. It means that every message you push must produce a notification visible to the user. Silent background pushes are not allowed. This is the biggest UX difference between Web Push and native push, as well as an important privacy safeguard. Want to synchronize data secretly in the background? The Web does not give you that option.
  • applicationServerKey is the VAPID public key. You can pass the base64url string directly.
  • A successful subscription returns a subscription shaped like this. Store the entire object on the backend:
ts
interface PushSubscription {
  endpoint: string;          // For example, https://fcm.googleapis.com/fcm/send/xxxx
  keys: {
    p256dh: string;          // ECDH public key generated by the browser, used to encrypt the payload
    auth: string;            // 16-byte auth secret used for encryption
  };
  expirationTime: number | null;
}

The endpoint is the unique delivery address for this device and browser within your application. The keys provide the material required for end-to-end payload encryption. Neither should be exposed.

Another frequently overlooked issue is that subscriptions can expire or become invalid. Browser upgrades, cleared caches, or Push service key rotation can invalidate an old subscription, after which further pushes return 404. One solution is to listen for the pushsubscriptionchange event in the Service Worker, resubscribe when it fires, and report the new endpoint to the backend to replace the old record. Unfortunately, browser support for this event is inconsistent, and Chrome has historically failed to fire it. The safer approach is to compare the locally cached subscription with the backend record whenever the user visits the site and refresh it when they differ.

5. Server-Side Delivery: One Call with web-push

Sending a push from the backend fundamentally means posting an encrypted request to the endpoint. But implementing encryption (RFC 8291), signing (JWT + ES256), and retries by hand would be foolish and dangerous. The web-push library handles all of it:

ts
// server.ts (Node.js)
import webpush from 'web-push';

// Configure the VAPID identity - run once when the process starts
webpush.setVapidDetails(
  'mailto:[email protected]', // How the Push service can contact you if something goes wrong
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

// Push one notification to a user - subscription is the object stored in step 4
async function pushNotification(
  subscription: webpush.PushSubscription,
  payload: { title: string; body: string; url?: string },
) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload), // The library automatically encrypts the payload end to end
      {
        TTL: 60 * 60 * 24, // Discard it if it cannot be delivered within one day; do not retry forever
      },
    );
  } catch (err) {
    // 410 Gone / 404: the subscription is invalid; delete it and stop sending to it
    if (err.statusCode === 410 || err.statusCode === 404) {
      await db.pushSubscription.delete({ where: { endpoint: subscription.endpoint } });
    }
  }
}

The library signs a JWT with the VAPID private key and puts it in the Authorization header, encrypts the payload using the subscription's p256dh + auth, and sends the POST request to the endpoint's domain. You only need to call sendNotification(subscription, string).

Do not overlook these two practical points:

  • Invalid subscriptions must be removed. When a user uninstalls the browser, clears its data, or revokes permission, the endpoint starts returning 404 or 410. Sending to dead addresses wastes quota, and at scale FCM may rate-limit you for it. Catching the status code and deleting the database record is basic hygiene.
  • Set a TTL. The default is four weeks, which is far too long for most use cases such as scheduled reminders and status changes. A "Live stream started" notification arriving 24 hours late only makes users think your product is broken.

6. Service Worker: Receive, Display, and Handle Clicks

When a push arrives, the browser awakens your registered Service Worker and fires a push event. Notification display and click handling both live here:

js
// public/sw.js
self.addEventListener('push', (event) => {
  let data;
  try {
    data = event.data.json(); // Corresponds to JSON.stringify(payload) on the server
  } catch {
    data = { title: 'Notification', 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 ?? '/' }, // Read this in notificationclick
      tag: data.tag, // The same tag replaces the previous notification, useful for unread-message counts
    }),
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const targetUrl = event.notification.data?.url ?? '/';

  event.waitUntil(
    (async () => {
      const allClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true,
      });
      // Prefer focusing an existing same-origin tab; open a new one only if none exists
      for (const client of allClients) {
        if (client.url.includes(targetUrl) && 'focus' in client) {
          return client.focus();
        }
      }
      return self.clients.openWindow(targetUrl);
    })(),
  );
});

A few details matter. event.waitUntil is required because the Service Worker is suspended as soon as the event finishes; without waitUntil, the notification may not be rendered in time. Use tag to deduplicate overlapping notifications. If you send three unread-message notifications in succession but want only one to remain, give them the same tag. In notificationclick, do not simply call openWindow and stop there. First check whether an existing tab can be focused for a much better experience.

7. UX: Permission Timing Makes or Breaks the Project

When Web Push projects fail, nine times out of ten the technology is not the problem. The user denied permission. There is one hard rule you must remember:

Once a user clicks "Block" in the browser, you cannot show the prompt a second time. The browser remembers the decision, and future calls to requestPermission() immediately return denied without displaying a prompt. The only recovery path is asking users to change it manually in browser settings, and 99% of them will not return.

Never call requestPermission() as soon as the page loads. That gambles away your only retention opportunity. The correct approach is:

  1. Explain first, then ask. Use your own UI, such as a card and button, to explain what notifications you will send and under which circumstances. Let the user actively click an "Enable notifications" button.
  2. Call requestPermission() only after the user clicks the button. Browsers require permission requests to be triggered by a user gesture, which aligns perfectly with this flow.
  3. Do not keep asking after a rejection. If you detect denied, show one link explaining how to enable notifications in settings, then stop.
  4. Send meaningful content. Push something useless like "We updated our privacy policy," and users will disable notifications the first time. Treat push access as a scarce resource and send only when the message is genuinely valuable to the recipient.

When possible, add quiet hours as well. Let users configure "Do not send notifications from 22:00 to 8:00," then have your backend delay delivery according to the user's time zone or skip it entirely. Returning control to users is essential for long-term retention. Go further and allow category-based subscriptions, such as "Only mentions of me" or "Only system security notifications." Authorization produces lasting retention only when noise is minimized. Sending everything to everyone is operational laziness, not product design.

8. Special Restrictions on iOS

iOS 16.4+ supports Web Push, but with one strict prerequisite: the user must first "Add to Home Screen" and install your site as a PWA. In a normal Safari tab, the Service Worker can run, but pushManager.subscribe does not work. Apple restricts Web Push to installed Web Apps.

This means:

  • You must first make the site a valid PWA with a manifest, Service Worker, icons, and standalone launch support before iOS users can access the "Add to Home Screen" option.
  • The desktop experience of receiving notifications whenever the browser is running is unavailable on iOS. Installation is required first.
  • Notifications on iOS go through the system notification center, respect Focus and Do Not Disturb modes, and behave like native notifications.

This adds more friction than on desktop, but it is not fatal for utility and community products whose users already have a reason to install the site. Turn "Add to Home Screen → Enable notifications" into a guided flow, and conversion on iOS can still be substantial.

Summary

  • Web Push lets you reach users after they leave your site. Every major desktop browser supports it, and iOS 16.4+ has filled the final gap, provided the PWA is installed on the Home Screen.
  • Memorize the seven-step flow: permission → register SW → subscribe and obtain endpoint → store on backend → VAPID-sign and encrypt the push → Push service relay → SW receives the push and displays it.
  • VAPID is the application's identity. It consists of a public-private key pair: give the public key to the browser, keep the private key on the server, and regenerate both if they are compromised.
  • userVisibleOnly: true is a hard constraint. The Web does not allow silent background pushes. Every push must be displayed, which is both a privacy boundary and a fundamental difference from native push.
  • Permission timing determines whether the project succeeds. Never ask on page load; after a rejection, there is no second chance. Explain first, ask later, and make every interruption worthwhile.

One final thought: the technology behind Web Push is not hard; restraint is. Writing sendNotification takes half a day. Deciding whether a message should be sent at all requires product judgment. Users grant notification access as an act of trust, so do not erode it with noise.

Related

Related Content