X

How to Add Web Push Notifications to a Web App

Web Push lets your server ask a browser to deliver a message to a service worker, even when the app tab is not open. The worker can then show a system notification. It is useful for a reply, a completed job, or another update a person has asked to receive. It is not a reliable channel for urgent alerts: browsers and operating systems control delivery, and a successful send does not prove that anyone saw the notification.

A small implementation needs four pieces: an HTTPS site, a service worker, a user-approved push subscription, and a server that sends an encrypted message to that subscription. This guide walks through those pieces and the privacy and failure cases to handle before launch.

How a web push message travels

  1. The app registers a service worker and explains what notifications will be used for.
  2. After a person opts in, the browser creates a PushSubscription. It contains a push-service endpoint and keys used to protect messages.
  3. The app sends that subscription to its own server, which stores it against the signed-in account.
  4. When an update is ready, the server sends a message to the endpoint using Web Push and VAPID.
  5. The browser's push service delivers the message to the service worker. The worker decides whether to display a notification and what to open when it is clicked.

The Push API documentation describes the service-worker and subscription parts of this flow. A notification is a separate user-interface decision: receiving a push message does not automatically display one.

Check the prerequisites and permission state

Web Push requires a secure context, normally HTTPS. Localhost is treated as secure for development, but deploy the app and its service worker over HTTPS before testing on a real domain. If the app already has a service worker, add push handlers to that worker rather than registering a second worker with a competing scope. Our guide to building an offline-capable web app with service workers and IndexedDB covers registration scope and worker lifecycle.

Ask for permission from a clear, user-initiated action such as an Enable notifications button. Explain what messages the person will receive before opening the browser prompt. Do not request permission on page load or make access to an unrelated feature depend on accepting notifications. The Notification permission guidance also recommends requesting permission in response to user interaction.

Check for support and the current permission before subscribing. If notifications are unsupported or denied, leave the rest of the app usable and offer an in-app inbox or another appropriate fallback. Permission values are granted, denied, and default; treat default as not enabled.

Subscribe from an explicit opt-in

The following browser-side example assumes the page has an accessible #push-status status region, an #enable-push button, and a server-rendered meta element containing the public VAPID key. Register the worker while initializing the app, before the person clicks the button. If your project already registers a worker, reuse that registration.

const status = document.querySelector("#push-status");
const enableButton = document.querySelector("#enable-push");
const vapidPublicKey = document.querySelector(
  'meta[name="vapid-public-key"]'
).content;

function decodeBase64Url(value) {
  const padding = "=".repeat((4 - (value.length % 4)) % 4);
  const base64 = (value + padding).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  return Uint8Array.from(raw, (character) => character.charCodeAt(0));
}

async function initializePush() {
  if (
    !("serviceWorker" in navigator) ||
    !("PushManager" in window) ||
    !("Notification" in window)
  ) {
    status.textContent = "Web Push is not available in this browser.";
    return;
  }

  const registration = await navigator.serviceWorker.register("/sw.js");
  await navigator.serviceWorker.ready;

  enableButton.addEventListener("click", () => {
    enablePush(registration)
      .then((message) => {
        status.textContent = message;
      })
      .catch((error) => {
        console.error("Web Push setup failed", error);
        status.textContent = "Notifications could not be enabled. Try again.";
      });
  });
}

async function enablePush(registration) {
  if (Notification.permission === "denied") {
    return "Notifications are blocked in browser settings.";
  }

  const permission =
    Notification.permission === "granted"
      ? "granted"
      : await Notification.requestPermission();

  if (permission !== "granted") {
    return "Notifications were not enabled.";
  }

  const subscription =
    (await registration.pushManager.getSubscription()) ??
    (await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: decodeBase64Url(vapidPublicKey),
    }));

  const response = await fetch("/api/push-subscriptions", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(subscription.toJSON()),
  });

  if (!response.ok) {
    throw new Error(`Could not save the subscription (${response.status}).`);
  }

  return "Notifications are enabled.";
}

initializePush().catch((error) => {
  console.error("Could not initialize Web Push", error);
  status.textContent = "Push setup could not be started.";
});

The subscribe() call belongs in the opt-in flow; some browsers require a user gesture, and userVisibleOnly: true is required by browsers including Chrome and Edge. The PushManager subscription documentation explains the options and gesture requirement. If a browser does not complete subscription after showing its permission prompt, keep the user in control and let them confirm the subscription with a second button rather than retrying automatically.

The POST /api/push-subscriptions route is your app's endpoint, not a browser API. Require the normal account authentication and CSRF protection for it. Associate the subscription with the account from the authenticated session, not an account ID supplied in the request. Validate the subscription shape, and make saving an existing endpoint an upsert so a person can re-enable notifications without creating duplicate rows.

Store subscriptions as private account data

A subscription includes an endpoint URL and encryption keys. MDN describes the endpoint as a unique capability URL: anyone who obtains it may be able to send messages to that subscription. Do not put endpoint URLs, authentication keys, or full subscription objects in public logs, analytics, source control, or error reports. Restrict access to the records, remove them when the user opts out or deletes their account, and avoid exposing a route that lets a client send to arbitrary URLs.

Keep the VAPID private key on the server. The public key is safe to provide to the browser for applicationServerKey; the private key is used to sign server requests. VAPID identifies the application server to a push service. It is not a user login, and its key pair is distinct from the per-subscription encryption keys used for message payloads.

Send a short message from the server

You can use a Web Push library rather than implementing encryption and protocol headers yourself. For example, the web-push library for Node.js exposes VAPID setup and a sendNotification() method:

import webpush from "web-push";

const subject = process.env.WEB_PUSH_SUBJECT;
const publicKey = process.env.WEB_PUSH_PUBLIC_KEY;
const privateKey = process.env.WEB_PUSH_PRIVATE_KEY;

if (!subject || !publicKey || !privateKey) {
  throw new Error("Web Push VAPID configuration is incomplete.");
}

webpush.setVapidDetails(subject, publicKey, privateKey);

export function sendPush(subscription, payload) {
  return webpush.sendNotification(subscription, JSON.stringify(payload), {
    TTL: 300,
  });
}

Generate the VAPID key pair once for the application, store it in your deployment's secret manager, and use the same public key in the page and the server configuration. Do not generate a new pair each time the application starts. The TTL option is a time-to-live in seconds; this example asks the push service to discard the message after five minutes if it cannot deliver it in time. Pick an expiry that matches the update. A delayed status that is no longer useful should not appear hours later.

Send only the data needed to create the notification. For example, a message can say that a reply is ready and link to the signed-in user's inbox rather than include the reply text. The Web Push encryption standard, RFC 8291, protects the payload from being read by the push service, but it does not stop a notification from revealing its text on a lock screen. Avoid secrets, private customer details, access tokens, and sensitive previews in both payloads and visible notification text.

Handle the push event and notification click

Add a push handler to the app's service worker. Validate server-side that payloads contain a title and a same-origin app path. The worker below also rejects an off-site URL before attaching it to the notification:

self.addEventListener("push", (event) => {
  if (!event.data) {
    return;
  }

  const payload = event.data.json();
  const target = new URL(payload.path || "/", self.location.origin);

  if (target.origin !== self.location.origin) {
    throw new Error("Push notification links must stay on this origin.");
  }

  event.waitUntil(
    self.registration.showNotification(payload.title, {
      body: payload.body,
      tag: payload.tag,
      data: { url: target.href },
    })
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();

  const target = new URL(event.notification.data.url, self.location.origin);
  if (target.origin !== self.location.origin) {
    throw new Error("Notification links must stay on this origin.");
  }

  event.waitUntil(clients.openWindow(target.href));
});

event.waitUntil() keeps the service worker alive while it displays the notification or opens the destination. A tag can help replace an older notification with a newer one about the same item. Keep the behavior simple and test it on the browsers and devices your audience uses; notification options and presentation are not identical everywhere.

Unsubscribe, clean up, and recover

Provide an in-app control to turn notifications off. When it is used, remove the endpoint from the server for the signed-in account and call subscription.unsubscribe() on the browser's current subscription. Report a failure if the server cannot record the opt-out, and let the person revoke permission through browser settings as another path. Re-check registration.pushManager.getSubscription() when the app is used again so the server can update a renewed subscription.

Handle send errors in the server's delivery job:

  • If the push service returns 404 or 410, treat that subscription as expired or invalid and remove it from the account's active subscriptions.
  • For temporary network errors or retryable service responses, use a bounded retry with backoff. Do not retry forever or send a message after its TTL has expired.
  • Treat a successful response from the push service as acceptance for delivery, not proof that the browser displayed the notification or the person read it.

RFC 8030 defines Web Push delivery, expiry, and response behavior. Push delivery is best effort: the device may be offline, the browser or operating system may suppress notifications, permission may be revoked, or a subscription may expire. Keep important state in the app itself and provide a fallback such as an in-app inbox. Do not use push as the only record of a payment, security event, or other critical transaction.

A practical launch checklist

Before enabling push for real users, confirm that:

  • The site and service worker are served over HTTPS, and the worker has the intended scope.
  • Permission is requested only after a clear opt-in action, with an accessible explanation and status message.
  • Unsupported browsers, denied permission, and subscription failures leave the core app usable.
  • Subscription records are authenticated, protected against CSRF, associated with the correct account, and removed on opt-out.
  • The VAPID private key stays on the server, and subscription endpoints are not written to routine logs.
  • Each push is useful, user-visible, short-lived when appropriate, and safe to show on a lock screen.
  • Push-service errors are surfaced to monitoring without logging secrets, stale endpoints are removed, and retries are bounded.
  • A person can disable notifications in the app and in the browser or operating-system settings.

Start with one notification that solves a clear user problem. Add more only when people can understand why they receive them and can easily stop them.

Sources

Categories: Development
Related Post