Skip to content

Push Notifications in Progressive Web Apps

Web push lets your server deliver a message to a user's browser at any time, even when no tab of your app is open: the browser wakes your service worker, which decrypts the payload and shows a system notification. It is the only standard, cross-browser way for a PWA to re-engage users from the background, and it runs in Chrome, Edge, Firefox and Samsung Internet on desktop and Android, in Safari on macOS, and in Home Screen web apps on iOS and iPadOS. This page walks through every moving part in production detail: the protocol actors, permission UX, PushManager.subscribe(), the PushSubscription object, server-side storage, sending with Node.js and Python, the push and notificationclick handlers, subscription churn, payload and delivery limits, and how to test all of it.

Key takeaways

  • Four parties are involved: your application server, the browser vendor's push service (FCM, Mozilla autopush, Apple Push Notification service, WNS), the browser, and your service worker. You never talk to the device directly; you POST an encrypted message to the subscription's endpoint URL.
  • Chrome, Edge and Safari require userVisibleOnly: true and a VAPID applicationServerKey (Firefox tolerates omitting either but enforces a quota instead), so always pass both. Every browser punishes pushes that do not end in a notification: Chrome and Edge show a generic one, Firefox burns a quota of 16, and Safari revokes the subscription after three misses.
  • Ask for permission only from a user gesture that clearly means "notify me", and call subscribe() as the first await in that handler. Prefetch the service worker registration and the VAPID key before the click.
  • A payload body is capped at 4,096 bytes, which leaves 3,993 bytes of plaintext after aes128gcm encryption. Send identifiers or small JSON, not documents.
  • Always set TTL deliberately (library defaults range from 0 seconds to 4 weeks), use Urgency to respect battery, and use Topic to collapse stale messages while the device is offline.
  • Treat HTTP 404 and 410 from the push service as "delete this subscription now". Re-send the current subscription from the page on every visit, because pushsubscriptionchange is not reliable across browsers.

How web push works: the four actors

Web push is a composition of three IETF RFCs and two W3C/WHATWG specifications. The Push API defines the JavaScript surface (PushManager, PushSubscription, the push event). RFC 8030 defines the HTTP protocol between your server and the push service. RFC 8291 defines how payloads are encrypted end to end, and RFC 8292 (VAPID) defines how your server proves its identity. The Notifications API turns a message into something the user can see. The byte-level details of the protocol, encryption and JWT signing live on The Web Push Protocol; this page focuses on building a working system.

Actor Operated by Responsibilities
Application server You Holds the VAPID key pair, stores subscriptions, decides what to send, encrypts payloads, signs VAPID JWTs, handles push service responses
Push service Browser vendor Issues subscription endpoints, authenticates your server, stores messages while the device is offline (up to TTL), delivers them over its own persistent connection
Browser (user agent) The user Holds the subscription's private ECDH key and auth secret, keeps a connection to the push service, decrypts payloads, starts your service worker
Service worker You (code), browser (lifecycle) Handles push, shows the notification, handles notificationclick, notificationclose and pushsubscriptionchange

The browser, not your origin, chooses the push service. You cannot choose or configure it, which is why your server must speak the standard protocol to several of them:

Browser Push service Endpoint host you will see
Chrome and most Chromium-based browsers Firebase Cloud Messaging (FCM) fcm.googleapis.com
Firefox (desktop and Android) Mozilla Push Service (autopush) updates.push.services.mozilla.com
Safari on macOS, Home Screen web apps on iOS and iPadOS Apple Push Notification service web.push.apple.com (Apple asks you to allow *.push.apple.com)
Microsoft Edge on Windows Windows Push Notification Services (WNS) a host under notify.windows.com

Treat the endpoint as an opaque URL. Mozilla's autopush documentation reserves the right to change any part of it and warns against storing only the last path segment. Store and use the full string exactly as the browser gave it to you.

The complete message flow

sequenceDiagram
    autonumber
    actor User
    participant Page
    participant SW as Service worker
    participant UA as Browser push client
    participant PS as Push service
    participant App as Application server
    Page->>App: GET VAPID public key
    User->>Page: clicks "Notify me"
    Page->>UA: pushManager.subscribe(options)
    UA->>User: permission prompt
    User-->>UA: Allow
    UA->>PS: create subscription with applicationServerKey
    PS-->>UA: endpoint URL
    UA-->>Page: PushSubscription with endpoint, p256dh, auth
    Page->>App: POST subscription JSON
    Note over App: later, something happens
    App->>App: encrypt payload and sign VAPID JWT
    App->>PS: POST endpoint with TTL, Urgency, Topic
    PS-->>App: 201 Created
    PS->>UA: deliver when the device is reachable
    UA->>UA: decrypt with the subscription private key
    UA->>SW: start worker and dispatch push
    SW->>User: showNotification()
    User->>SW: clicks the notification
    SW->>Page: focus() or openWindow(url)

A 201 Created from the push service means accepted, not delivered. RFC 8030 lets you request a delivery receipt with Prefer: respond-async, but Mozilla's autopush documents that it cannot support receipts and only returns 201, and none of the major services gives you reliable end-to-end delivery confirmation. If you need to know that a user saw a message, have the service worker report back (see the notificationclose and click handlers below).

What each party can see

The payload is encrypted with keys that only the browser holds, so the push service cannot read it. The Push API specification is explicit about what still leaks: the push service sees the timing, frequency and size of your messages, and the only mitigation it names is padding the payload. It also sees the Topic header, which RFC 8030 says is neither encrypted nor authenticated. Do not put user data in topics. Some libraries pad for you: the Go library webpush-go pads every message to a full 4,096-byte record by default, while web-push for Node.js and pywebpush send the minimum size.

The push service also knows your VAPID public key, the sub contact in your JWT, and which endpoint (which browser installation) each message targets. The endpoint itself must not expose information about the user, per the spec, and a deactivated endpoint must never be reused for a new subscription, so an endpoint is a stable pseudonymous identifier of one browser profile for one origin. Treat it as personal data in your storage and logs.

Delivery is best-effort

Once accepted, a message is stored by the push service until the device connects or TTL elapses, whichever comes first. The browser acknowledges the message after dispatching it. If the push event fails (a rejected waitUntil() promise), the spec lets the browser refuse the acknowledgment so the push service retries, and recommends allowing at least three attempts before acknowledging a message that keeps failing. A message whose payload fails to decrypt is acknowledged and dropped without any push event, so a key mismatch on your server is silent on the client.

Prerequisites and feature detection

Push has more preconditions than most web APIs. All of these must hold before subscribe() can succeed:

Requirement Why What happens otherwise
Secure context PushManager and PushSubscription are [SecureContext] The interfaces are not exposed
An active service worker Subscriptions are attached to a registration Spec: InvalidStateError. Chrome: AbortError "Subscription failed - no active Service Worker"
Notification permission granted (or grantable) Push permission is tied to notification permission in every engine NotAllowedError
userVisibleOnly: true Chrome, Edge and Safari reject silent subscriptions Chrome: NotAllowedError, with a console error explaining the requirement
An applicationServerKey Chrome's pre-VAPID gcm_sender_id path relied on FCM's legacy server-key API, deprecated in June 2023 and shut down from June 2024; Apple requires VAPID Chrome: AbortError "missing applicationServerKey, and gcm_sender_id not found in manifest"
Not in a private window (Chrome) Chrome denies push in Incognito and makes the denial indistinguishable from a user "Block" NotAllowedError
iOS and iPadOS: a Home Screen web app Safari tabs on iOS do not expose web push The Notification interface is undefined in a Safari tab

On Apple platforms, Lockdown Mode also disables the Push API and Notifications: WebKit marks both features as disabled in Lockdown Mode. Android WebView does not support the Push API at all, so a PWA wrapped in a plain WebView cannot use it; a Trusted Web Activity runs in the full browser and can.

Detect features, not browsers. The one place where a user-agent check is justified is iOS guidance: when PushManager or Notification is missing and the device looks like an iPhone or iPad, tell the user to add the app to the Home Screen instead of saying "your browser is not supported". The full iOS story, including Declarative Web Push and Focus integration, is on Web Push on iOS & Safari.

VAPID keys: your server's identity

Voluntary Application Server Identification (RFC 8292) binds a subscription to a key pair that you own. When the browser subscribes, it passes your public key to the push service. From then on the push service accepts messages for that endpoint only if they carry a JWT signed with the matching private key. Anyone who steals subscription endpoints without your private key cannot send to them.

The key pair is an ECDSA key on the P-256 curve:

  • The public key is an uncompressed point: 65 bytes starting with 0x04, which is 87 characters in unpadded base64url. It goes into applicationServerKey in the browser and into the k= parameter of the Authorization: vapid t=..., k=... header.
  • The private key is a 32-byte scalar, 43 characters in unpadded base64url. It never leaves your server.

Generate a pair once per environment with the web-push CLI, which prints both keys in base64url:

Terminal
npx web-push generate-vapid-keys --json
# {"publicKey":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds","privateKey":"..."}

Store the private key in your secret manager, not in the repository. Both web-push and pywebpush accept the raw base64url private key produced above (pywebpush also accepts a PEM file or DER string).

Rotating VAPID keys

A subscription is permanently bound to the applicationServerKey it was created with. The spec says options of an existing subscription cannot change, and calling subscribe() with a different key while a subscription exists rejects with InvalidStateError. Chrome's message spells out the fix: "A subscription with a different applicationServerKey (or gcm_sender_id) already exists; to change the applicationServerKey, unsubscribe then resubscribe." Rotation is therefore a client-side migration:

  1. Publish the new public key with a new key ID from your key endpoint, and keep the old private key on the server.
  2. Store which key ID each subscription was created with (the vapid_key_id column below), and sign each message with the matching private key.
  3. On each page load, compare subscription.options.applicationServerKey with the current key. If they differ, unsubscribe() and subscribe again. Where permission is already granted, Chrome allows this without a prompt; engines that require a user gesture for subscribe() may need a button press.
  4. Retire the old private key once no active subscriptions reference it.

Never rotate by simply replacing the key on the server: every existing subscription then fails with 401 or 403 until users happen to revisit, and Safari users whose subscriptions go unrenewed are lost.

Asking for permission without burning it

Push permission and notification permission are the same decision in every shipping engine: calling pushManager.subscribe() shows the notification prompt if needed, and granting it grants both. The permission has three states, exposed as Notification.permission ("default", "granted", "denied") and through registration.pushManager.permissionState({ userVisibleOnly: true }) or navigator.permissions.query({ name: "notifications" }) ("prompt", "granted", "denied").

"denied" is effectively permanent. Once a user clicks Block, no API can prompt again; only the user can reset it from site settings. That makes the first prompt the most valuable interaction you have, and browsers actively police how you use it:

Browser Prompt rules Anti-abuse behavior
Chrome and Edge No user-gesture requirement for the prompt itself Chrome can replace the prompt with a quieter UI (a bell icon in the address bar), which users can turn on with the "Use quieter messaging" setting. Chrome also blocks notifications from sites it classifies as abusive and may remove notification permission from sites the user has not visited in a while or that it finds disruptive, per Google's Chrome help
Firefox Since Firefox 72 on desktop (79 on Android), Notification.requestPermission() and subscribe() must be called from a user gesture such as click Requests outside a gesture are rejected without a prompt
Safari on macOS "Requesting a push subscription requires an explicit user gesture" (WebKit) Silent pushes revoke the subscription (see below)
Safari on iOS and iPadOS Gesture required, and only inside a Home Screen web app The permission then appears per app in iOS Settings, like a native app's

The pattern that works everywhere is a two-step opt-in:

  1. Show your own in-page UI at a moment when notifications are obviously useful: after the user follows a thread, sets a price alert, or places an order. Explain what you will send and how often.
  2. Only when the user clicks your "Turn on notifications" button, call subscribe(). The native prompt now answers a question the user already said yes to.

If the user dismisses your in-page UI, you have lost nothing and can ask again later. Never trigger the native prompt on page load, on scroll, or from a timer. The Permissions page covers permission state tracking across all capabilities, and App-Like UX Patterns covers prompt design.

Keep the user activation alive

Firefox and Safari check for transient user activation when you call subscribe(). Activation expires after a short, browser-defined interval, so do not await a network request (for example, fetching the VAPID key) or navigator.serviceWorker.ready inside the click handler before calling subscribe(). Resolve both when the page loads and keep them in variables, as the client code below does.

Subscribing: PushManager.subscribe() in depth

PushManager is available as registration.pushManager on a ServiceWorkerRegistration, in both pages and workers. Safari 18.4 and later also expose window.pushManager for Declarative Web Push, which subscribes without any service worker. Its IDL is small:

Push API (excerpt)
[Exposed=(Window,Worker), SecureContext]
interface PushManager {
  [SameObject] static readonly attribute FrozenArray<DOMString> supportedContentEncodings;
  Promise<PushSubscription> subscribe(optional PushSubscriptionOptionsInit options = {});
  Promise<PushSubscription?> getSubscription();
  Promise<PermissionState> permissionState(optional PushSubscriptionOptionsInit options = {});
};

dictionary PushSubscriptionOptionsInit {
  boolean userVisibleOnly = false;
  (BufferSource or DOMString)? applicationServerKey = null;
};

The two options

userVisibleOnly is a promise from you to the browser: every push message sent to this subscription will result in something the user can see, normally a notification. The spec leaves enforcement to the browser, and in practice Chrome, Edge and Safari refuse subscriptions without it. Firefox accepts either value but does not expose the option at all (subscription.options.userVisibleOnly is undefined there), because it enforces a quota instead. Always pass true.

applicationServerKey is your VAPID public key as a BufferSource (typically a Uint8Array of 65 bytes) or a base64url string. String support was added later (Chrome 76), Chrome rejects padded base64url with InvalidCharacterError, and some older engines only accept buffers, so decoding the key to a Uint8Array yourself is the most portable choice. The spec requires the key to be a valid P-256 point, and different from the ECDH key used for message encryption.

What subscribe() does, step by step

The Push API algorithm, condensed:

  1. If the PushManager belongs to a Window (Declarative Web Push), the scope is the origin's root /. Otherwise the scope is the registration's scope.
  2. If the scope's scheme is not https, reject with NotAllowedError. (Chromium-based browsers nevertheless allow http://localhost during development.)
  3. If userVisibleOnly is false and the browser requires true, reject with NotAllowedError.
  4. If applicationServerKey is null and the push service requires one, reject with NotSupportedError.
  5. If the key is a string, base64url-decode it; on failure reject with InvalidCharacterError. If the bytes are not a valid P-256 point, reject with InvalidAccessError.
  6. In parallel: if the registration has no active worker, reject with InvalidStateError. Look up the existing subscription for this scope.
  7. Request permission to use "push". If the result is "denied", reject with NotAllowedError.
  8. If a subscription exists: reject with AbortError if it is in an error state, reject with InvalidStateError if its options differ from the ones passed (buffers are compared by content), and otherwise resolve with the existing subscription.
  9. Otherwise create a subscription: generate a new P-256 ECDH key pair and a 16-byte auth secret (RFC 8291), request an endpoint from the push service including your applicationServerKey, and resolve with the new PushSubscription.

Step 8 is what makes subscribe() idempotent: calling it again with identical options returns the same subscription and endpoint, so it is safe to call on every visit when permission is already granted.

Exceptions and what they mean in practice

Exception Spec meaning What you usually did wrong
NotAllowedError Insecure scope, silent subscription refused, or permission denied User clicked Block, the call happened outside a gesture (Firefox, Safari), userVisibleOnly was omitted (Chrome), or the page runs in Chrome Incognito
InvalidStateError No active worker, or a subscription exists with different options You changed the VAPID key without unsubscribing first, or called before the worker activated
InvalidCharacterError applicationServerKey string is not base64url You passed standard base64 (+, /) or kept = padding
InvalidAccessError Key is not a valid P-256 point You passed the private key, a PEM string, or a truncated key
NotSupportedError The push service requires a key and none was given You omitted applicationServerKey
AbortError Error with the existing subscription (spec); Chrome also uses it for push service, network and storage failures Transient: offer a retry, and log the message text, which in Chrome names the cause

getSubscription() and permissionState()

getSubscription() resolves with the registration's current subscription or null, never prompts, and does not need a user gesture. Use it on every page load to render the right UI state and to re-sync the subscription with your server.

permissionState(options) resolves with "prompt", "granted" or "denied" for the push permission without prompting. Pass { userVisibleOnly: true }: the Permissions API defines {name: "push", userVisibleOnly: false} as a stronger permission than the user-visible one, and browsers that do not grant silent push report the stronger one differently.

PushManager.supportedContentEncodings lists the payload encodings the browser can decrypt. Every engine must support aes128gcm (RFC 8291). Chrome still lists the pre-standard aesgcm as well; Firefox has stopped advertising aesgcm by default, although it can still decrypt it. Always send aes128gcm.

Production client code

This module separates the three things the UI needs: a capability report, a startup routine that never prompts, and an opt-in function that must run inside a click handler. It handles key rotation, re-syncs on every visit and keeps the user activation intact.

push-client.js
// push-client.js: page-side subscription management (ES module).
const API = {
  publicKey: "/api/push/vapid-public-key",
  subscriptions: "/api/push/subscriptions",
};

let registration = null; // resolved once at startup, never inside a click handler
let vapidKey = null; // { id, bytes } prefetched so subscribe() runs inside the gesture

/** Capability report used to decide which UI to render. */
export function getPushCapability() {
  const secure = window.isSecureContext;
  const hasSW = "serviceWorker" in navigator;
  const hasPush = "PushManager" in window;
  const hasNotification = "Notification" in window;
  const standalone =
    window.matchMedia("(display-mode: standalone)").matches ||
    navigator.standalone === true; // legacy iOS flag
  // UA sniffing is used ONLY to show "add to Home Screen" guidance, never to gate the API.
  const looksLikeIOS =
    /iPad|iPhone|iPod/.test(navigator.userAgent) ||
    (/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);

  if (!secure || !hasSW) return { supported: false, reason: "insecure-or-no-sw" };
  if (hasPush && hasNotification) return { supported: true, standalone };
  if (looksLikeIOS && !standalone) return { supported: false, reason: "ios-needs-home-screen" };
  return { supported: false, reason: "no-push-api" };
}

/** Call once on page load. Never prompts. Returns the current UI state. */
export async function initPush() {
  const capability = getPushCapability();
  if (!capability.supported) return { state: "unsupported", reason: capability.reason };

  registration = await navigator.serviceWorker.ready;
  vapidKey = await fetchVapidKey();

  if (Notification.permission === "denied") return { state: "blocked" };

  const subscription = await registration.pushManager.getSubscription();
  if (!subscription) {
    return { state: Notification.permission === "granted" ? "granted-unsubscribed" : "prompt" };
  }

  // The server key changed (rotation): the old subscription can no longer be used.
  if (!sameBytes(subscription.options.applicationServerKey, vapidKey.bytes)) {
    await subscription.unsubscribe().catch(() => {});
    await deleteOnServer(subscription.endpoint).catch(() => {});
    return { state: "granted-unsubscribed" };
  }

  // Re-send on every load: cheap, idempotent, and repairs any server-side loss.
  await saveOnServer(subscription).catch((err) => console.warn("push sync failed", err));
  return { state: "subscribed" };
}

/** Must be called synchronously from a click/tap handler. */
export async function enablePush() {
  if (!registration || !vapidKey) throw new Error("initPush() has not completed");
  let subscription;
  try {
    // First await in the handler: keeps the transient user activation intact.
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: vapidKey.bytes,
    });
  } catch (err) {
    if (err.name === "NotAllowedError") {
      return { state: Notification.permission === "denied" ? "blocked" : "prompt" };
    }
    if (err.name === "InvalidStateError") {
      // An existing subscription was created with a different key: replace it.
      const stale = await registration.pushManager.getSubscription();
      await stale?.unsubscribe();
      return { state: "granted-unsubscribed", retry: true };
    }
    throw err; // AbortError: push service or network failure; show a retry option
  }
  await saveOnServer(subscription);
  return { state: "subscribed" };
}

export async function disablePush() {
  const subscription = await registration?.pushManager.getSubscription();
  if (!subscription) return { state: "granted-unsubscribed" };
  // Tell the server first so it stops sending even if unsubscribe() fails.
  await deleteOnServer(subscription.endpoint).catch(() => {});
  const removed = await subscription.unsubscribe(); // false if already deactivated
  return { state: "granted-unsubscribed", removed };
}

async function fetchVapidKey() {
  const res = await fetch(API.publicKey, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`VAPID key request failed: ${res.status}`);
  const { keyId, publicKey } = await res.json(); // base64url, 65 bytes decoded
  return { id: keyId, bytes: base64UrlToBytes(publicKey) };
}

async function saveOnServer(subscription) {
  const res = await fetch(API.subscriptions, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    body: JSON.stringify({ keyId: vapidKey.id, subscription: subscription.toJSON() }),
  });
  if (!res.ok) throw new Error(`Saving subscription failed: ${res.status}`);
}

async function deleteOnServer(endpoint) {
  const res = await fetch(API.subscriptions, {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    keepalive: true, // survives an immediate navigation or tab close
    body: JSON.stringify({ endpoint }),
  });
  if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
}

function base64UrlToBytes(value) {
  const padded = value + "=".repeat((4 - (value.length % 4)) % 4);
  const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
  return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

function sameBytes(buffer, bytes) {
  if (!buffer) return false;
  const a = new Uint8Array(buffer);
  return a.length === bytes.length && a.every((v, i) => v === bytes[i]);
}

Wire it to your UI so that the button handler calls enablePush() directly:

settings.js
import { initPush, enablePush, disablePush } from "./push-client.js";

const button = document.querySelector("#notify-toggle");
const status = document.querySelector("#notify-status");

function render({ state, reason }) {
  button.hidden = state === "unsupported" || state === "blocked";
  button.textContent = state === "subscribed" ? "Turn off notifications" : "Turn on notifications";
  status.textContent = {
    unsupported: reason === "ios-needs-home-screen"
      ? "Add this app to your Home Screen to enable notifications."
      : "Notifications are not available in this browser.",
    blocked: "Notifications are blocked. Re-enable them in your browser's site settings.",
    prompt: "",
    "granted-unsubscribed": "",
    subscribed: "You will be notified about replies and mentions.",
  }[state];
}

let current = await initPush().catch(() => ({ state: "unsupported" }));
render(current);

button.addEventListener("click", async () => {
  button.disabled = true;
  try {
    current = current.state === "subscribed" ? await disablePush() : await enablePush();
  } catch (err) {
    console.error(err);
    status.textContent = "Something went wrong. Please try again.";
  } finally {
    button.disabled = false;
    render(current);
  }
});

The PushSubscription object

A PushSubscription is everything your server needs to reach one browser profile on one origin:

Push API (excerpt)
[Exposed=(Window,Worker), SecureContext]
interface PushSubscription {
  readonly attribute USVString endpoint;
  readonly attribute EpochTimeStamp? expirationTime;
  [SameObject] readonly attribute PushSubscriptionOptions options;
  ArrayBuffer? getKey(PushEncryptionKeyName name);   // "p256dh" | "auth"
  Promise<boolean> unsubscribe();
  PushSubscriptionJSON toJSON();
};
Member Type Meaning
endpoint USVString The capability URL your server POSTs to. Unique per subscription and never reused after deactivation
expirationTime EpochTimeStamp or null Milliseconds since the epoch when the subscription will be deactivated, if the push service provided one. Browsers commonly return null
options PushSubscriptionOptions The userVisibleOnly and applicationServerKey (as an ArrayBuffer) used to create it; toJSON() does not include them
getKey("p256dh") ArrayBuffer The browser's P-256 ECDH public key: 65 bytes, uncompressed, starting with 0x04. Each call returns a new buffer
getKey("auth") ArrayBuffer The 16-byte authentication secret from RFC 8291
unsubscribe() Promise<boolean> Deactivates the subscription; resolves false if it was already deactivated
toJSON() PushSubscriptionJSON { endpoint, expirationTime, keys: { p256dh, auth } } with keys as unpadded base64url strings

JSON.stringify(subscription) calls toJSON() for you. A typical serialized subscription looks like this (values shortened):

subscription.json
{
  "endpoint": "https://fcm.googleapis.com/fcm/send/dJ1u0...:APA91bE...",
  "expirationTime": null,
  "keys": {
    "p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
    "auth": "tBHItJI5svbpez7KI4CCXg"
  }
}

The endpoint is a bearer capability for delivery, protected by VAPID, and the keys are what makes a payload decryptable. Anyone who holds all three values and your VAPID private key can message that user. Store subscriptions like credentials: encrypted at rest if your policy requires it, never in client-side analytics, and never in logs.

expirationTime deserves a note. The spec lets a push service set an expiry and says the browser should refresh the subscription before it expires and fire pushsubscriptionchange. In practice you cannot rely on either: most subscriptions report null, and subscriptions also die without warning (the user clears site data, revokes permission, or the push service expires the endpoint). Your server learns about those only from a 404 or 410 on the next send. Store expirationTime when it is present and treat it as a hint.

Storing subscriptions on the server

One user has many subscriptions (phone, laptop, work profile), and one subscription can move between users on a shared device. Model it that way:

migrations/0007_push_subscriptions.sql
CREATE TABLE push_subscriptions (
  id               bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id          bigint      NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  endpoint         text        NOT NULL UNIQUE,   -- full URL, exactly as received
  p256dh           text        NOT NULL,          -- base64url, 65 bytes decoded
  auth             text        NOT NULL,          -- base64url, 16 bytes decoded
  expiration_time  timestamptz,                   -- usually NULL
  vapid_key_id     text        NOT NULL,          -- which key pair it was created with
  push_service     text        NOT NULL,          -- endpoint host, for metrics and throttling
  user_agent       text,
  created_at       timestamptz NOT NULL DEFAULT now(),
  last_seen_at     timestamptz NOT NULL DEFAULT now(), -- last re-sync from a page load
  last_success_at  timestamptz,                   -- last 201 from the push service
  failure_count    integer     NOT NULL DEFAULT 0
);
CREATE INDEX push_subscriptions_user_id_idx ON push_subscriptions (user_id);

Design notes that matter in production:

  • Upsert on endpoint. The page re-sends the subscription on every load, and the same endpoint may arrive under a different user after a logout and login on a shared device. ON CONFLICT (endpoint) DO UPDATE handles both and keeps one row per browser.
  • Validate before storing. Parse the endpoint as a URL, require https:, and check the host against the push services you support. Without that allowlist, anyone can make your sender POST to an arbitrary URL, which is a server-side request forgery vector. Also check that p256dh decodes to 65 bytes starting with 0x04 and auth to 16 bytes, so malformed rows fail at write time rather than at send time.
  • Authenticate and protect against CSRF. The subscription endpoints change who receives a user's notifications. Require a session and use your normal CSRF defenses.
  • Keep last_seen_at and last_success_at. A subscription that has not been re-synced for months and whose sends keep failing is a candidate for pruning even if the push service never returned 410.
  • Handle logout explicitly. Either unsubscribe the browser (the user stops receiving anything) or detach the row from the user (DELETE it) so the next person on the device does not get the previous user's messages.

The routes below implement this for Express 5 and PostgreSQL:

server/push-routes.js
import express from "express";
import { pool } from "./db.js";

export const pushRouter = express.Router();

// Push services you are willing to call. Anything else is rejected to prevent
// your sender from being used for server-side request forgery (SSRF).
const PUSH_HOST_SUFFIXES = [
  "fcm.googleapis.com", // Chrome and most Chromium browsers
  "push.services.mozilla.com", // Firefox (updates.push.services.mozilla.com)
  "push.apple.com", // Safari and iOS/iPadOS Home Screen web apps
  "notify.windows.com", // Microsoft Edge on Windows (WNS)
];
const B64URL = /^[A-Za-z0-9_-]+$/;

function parseSubscription(input) {
  if (!input || typeof input.endpoint !== "string" || input.endpoint.length > 2048) {
    throw new TypeError("endpoint missing or too long");
  }
  const url = new URL(input.endpoint);
  const hostOk = PUSH_HOST_SUFFIXES.some(
    (suffix) => url.hostname === suffix || url.hostname.endsWith(`.${suffix}`),
  );
  if (url.protocol !== "https:" || !hostOk) throw new TypeError("unknown push service");

  const { p256dh, auth } = input.keys ?? {};
  if (!B64URL.test(p256dh ?? "") || !B64URL.test(auth ?? "")) throw new TypeError("bad keys");
  const pub = Buffer.from(p256dh, "base64url");
  const secret = Buffer.from(auth, "base64url");
  // RFC 8291: uncompressed P-256 point (65 bytes, 0x04 prefix) and a 16-byte auth secret.
  if (pub.length !== 65 || pub[0] !== 0x04 || secret.length !== 16) {
    throw new TypeError("keys have the wrong length");
  }
  const expirationTime =
    typeof input.expirationTime === "number" ? new Date(input.expirationTime) : null;
  return { endpoint: url.href, p256dh, auth, expirationTime, service: url.hostname };
}

// Public key for the browser. keyId lets you rotate keys without guessing.
pushRouter.get("/api/push/vapid-public-key", (req, res) => {
  res.set("Cache-Control", "public, max-age=3600");
  res.json({ keyId: process.env.VAPID_KEY_ID, publicKey: process.env.VAPID_PUBLIC_KEY });
});

pushRouter.post("/api/push/subscriptions", express.json({ limit: "8kb" }), async (req, res) => {
  if (!req.user) return res.sendStatus(401); // your session middleware sets req.user
  let sub;
  try {
    sub = parseSubscription(req.body?.subscription);
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
  // Upsert by endpoint: the same browser re-sends on every page load, and a
  // shared device may move the endpoint to a different signed-in user.
  await pool.query(
    `INSERT INTO push_subscriptions
       (user_id, endpoint, p256dh, auth, expiration_time, vapid_key_id, push_service, user_agent)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
     ON CONFLICT (endpoint) DO UPDATE SET
       user_id = EXCLUDED.user_id,
       p256dh = EXCLUDED.p256dh,
       auth = EXCLUDED.auth,
       expiration_time = EXCLUDED.expiration_time,
       vapid_key_id = EXCLUDED.vapid_key_id,
       user_agent = EXCLUDED.user_agent,
       last_seen_at = now(),
       failure_count = 0`,
    [
      req.user.id,
      sub.endpoint,
      sub.p256dh,
      sub.auth,
      sub.expirationTime,
      String(req.body.keyId ?? process.env.VAPID_KEY_ID),
      sub.service,
      req.get("user-agent")?.slice(0, 512) ?? null,
    ],
  );
  res.sendStatus(204);
});

// Called by the service worker's pushsubscriptionchange handler. It may run
// without a page, so it authenticates with the session cookie like any fetch.
pushRouter.post("/api/push/subscriptions/replace", express.json({ limit: "8kb" }), async (req, res) => {
  if (!req.user) return res.sendStatus(401);
  let sub;
  try {
    sub = parseSubscription(req.body?.subscription);
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // A browser-initiated refresh keeps the old applicationServerKey, so inherit the
    // old row's key ID unless the worker subscribed afresh and told us which key it used.
    let keyId = typeof req.body.keyId === "string" ? req.body.keyId : null;
    if (typeof req.body.oldEndpoint === "string") {
      const { rows } = await client.query(
        "DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2 RETURNING vapid_key_id",
        [req.body.oldEndpoint, req.user.id],
      );
      keyId ??= rows[0]?.vapid_key_id ?? null;
    }
    await client.query(
      `INSERT INTO push_subscriptions
         (user_id, endpoint, p256dh, auth, expiration_time, vapid_key_id, push_service)
       VALUES ($1, $2, $3, $4, $5, $6, $7)
       ON CONFLICT (endpoint) DO UPDATE SET
         user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth,
         vapid_key_id = EXCLUDED.vapid_key_id, last_seen_at = now(), failure_count = 0`,
      [req.user.id, sub.endpoint, sub.p256dh, sub.auth, sub.expirationTime,
       keyId ?? process.env.VAPID_KEY_ID, sub.service],
    );
    await client.query("COMMIT");
    res.sendStatus(204);
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
});

pushRouter.delete("/api/push/subscriptions", express.json({ limit: "8kb" }), async (req, res) => {
  if (!req.user) return res.sendStatus(401);
  const endpoint = req.body?.endpoint;
  if (typeof endpoint !== "string") return res.sendStatus(400);
  const { rowCount } = await pool.query(
    "DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2",
    [endpoint, req.user.id],
  );
  res.sendStatus(rowCount ? 204 : 404);
});

Maintain the allowlist

The hosts above are the ones the major browsers use today. If a new browser or push service appears, subscriptions from it are rejected with 400 until you add its host, so log rejections and alert on them rather than failing silently.

Sending push messages from your server

Anatomy of a push request

Every library ultimately sends the same HTTP request to the subscription's endpoint. Seeing it raw makes the library options and the error responses easier to reason about:

What your server sends
POST /fcm/send/dJ1u0...:APA91bE... HTTP/1.1
Host: fcm.googleapis.com
TTL: 3600
Urgency: high
Topic: inbox-count
Content-Type: application/octet-stream
Content-Encoding: aes128gcm
Content-Length: 181
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOi..., k=BLc4xRzKlKORKWlbdgFaBr...

<binary body: 86-byte aes128gcm header, then ciphertext, then a 16-byte authentication tag>
Header Required Rules
TTL Yes Seconds the push service may store the message. RFC 8030 requires a 400 if it is missing. The service may lower it and reports the value it applied in the response's TTL header
Urgency No very-low, low, normal (default) or high. More than one value is a 400
Topic No At most 32 characters from the base64url alphabet. Replaces any stored, undelivered message with the same topic
Content-Encoding With a payload aes128gcm, exactly one value. RFC 8291 forbids any other coding, in particular compression, which could leak content
Authorization Yes for VAPID subscriptions vapid t=<JWT>, k=<public key>. The JWT is ES256-signed with claims aud (the endpoint's origin), exp (no more than 24 hours ahead) and sub (a mailto: or https: contact)
Content-Type Recommended application/octet-stream for encrypted bodies

Three VAPID details cause most 401/403 responses in practice. First, aud must be the origin of the endpoint (https://fcm.googleapis.com, not the full URL and not your own origin), so you need one JWT per push service. Second, exp must be in the future but no more than 24 hours away; servers with a drifting clock produce intermittent failures. Third, Apple's push service rejects some sub values: the web-push documentation notes that a subject of https://localhost fails with a BadJwtToken error. Apple also asks senders not to refresh the JWT more often than once per hour, which is a good reason to cache the signed header per audience, as the senders below do.

Node.js with web-push

web-push is the reference Node.js implementation (version 3.6.7 at the time of writing, Node.js 16 or later). Its surface is small:

Function Purpose
generateVAPIDKeys() Returns { publicKey, privateKey } as base64url strings
setVapidDetails(subject, publicKey, privateKey) Sets default VAPID details for all sends
sendNotification(subscription, payload?, options?) Encrypts, signs and POSTs. Resolves with { statusCode, body, headers } for any 2xx; rejects with WebPushError otherwise
generateRequestDetails(subscription, payload?, options?) Returns the method, headers and encrypted body without sending, useful for your own HTTP client or queue
getVapidHeaders(audience, subject, publicKey, privateKey, contentEncoding, expiration?) Returns the Authorization header for one audience, which you can cache
WebPushError Error class with statusCode, headers, body and endpoint

sendNotification() options and their defaults:

Option Default Notes
TTL 2419200 (4 weeks) Almost always too long for real messages; set it per message
urgency "normal" Validated against the four RFC values
topic none Validated: base64url characters, at most 32
contentEncoding "aes128gcm" "aesgcm" exists only for very old browsers
vapidDetails value from setVapidDetails() Pass null to suppress signing when you supply your own Authorization header
headers {} Extra headers; the library throws if a header duplicates a top-level option such as TTL
timeout none Socket timeout in milliseconds
proxy, agent none Outbound proxy or a custom https.Agent for connection reuse

The library signs a new JWT for every call by default, which is wasteful at volume and conflicts with Apple's once-per-hour guidance. The sender below caches one header per push service, validates payload size before encrypting, retries only transient failures, honors Retry-After, deletes subscriptions on 404/410, and bounds concurrency during fan-out:

server/push-sender.js
import webpush from "web-push";
import { pool } from "./db.js";

const { WebPushError } = webpush;

const VAPID = {
  subject: process.env.VAPID_SUBJECT, // "mailto:[email protected]" or an https: URL
  publicKey: process.env.VAPID_PUBLIC_KEY, // base64url, 65 bytes decoded
  privateKey: process.env.VAPID_PRIVATE_KEY, // base64url, 32 bytes decoded
};
const MAX_PLAINTEXT_BYTES = 3993; // 4096-byte body minus aes128gcm overhead (RFC 8291)
const JWT_LIFETIME_S = 12 * 60 * 60; // RFC 8292 caps exp at 24 hours
const JWT_REFRESH_MARGIN_S = 60 * 60;
const CONCURRENCY = 50;
const MAX_ATTEMPTS = 3;

// One signed VAPID header per push-service origin, reused until close to expiry.
// Apple asks senders not to refresh the JWT more often than once per hour.
const vapidHeaderCache = new Map();

function vapidAuthorization(endpoint) {
  const audience = new URL(endpoint).origin;
  const now = Math.floor(Date.now() / 1000);
  const cached = vapidHeaderCache.get(audience);
  if (cached && cached.exp - JWT_REFRESH_MARGIN_S > now) return cached.header;
  const exp = now + JWT_LIFETIME_S;
  const { Authorization } = webpush.getVapidHeaders(
    audience, VAPID.subject, VAPID.publicKey, VAPID.privateKey, "aes128gcm", exp,
  );
  vapidHeaderCache.set(audience, { header: Authorization, exp });
  return Authorization;
}

/**
 * Send one message to one stored subscription row.
 * @returns {Promise<{ok: boolean, status?: number, retryAfterMs?: number, gone?: boolean, reason?: string}>}
 */
export async function sendToSubscription(row, message, { ttl = 3600, urgency = "normal", topic } = {}) {
  const payload = typeof message === "string" ? message : JSON.stringify(message);
  const bytes = Buffer.byteLength(payload, "utf8");
  if (bytes > MAX_PLAINTEXT_BYTES) {
    throw new RangeError(`push payload is ${bytes} bytes, limit ${MAX_PLAINTEXT_BYTES}`);
  }
  const subscription = { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } };

  for (let attempt = 1; ; attempt++) {
    try {
      const res = await webpush.sendNotification(subscription, payload, {
        TTL: ttl,
        urgency,
        topic, // undefined is ignored; max 32 base64url characters
        vapidDetails: null, // skip per-request signing: we pass a cached header
        headers: { Authorization: vapidAuthorization(row.endpoint) },
        timeout: 10_000,
      });
      await pool.query(
        "UPDATE push_subscriptions SET last_success_at = now(), failure_count = 0 WHERE id = $1",
        [row.id],
      );
      return { ok: true, status: res.statusCode };
    } catch (err) {
      const outcome = await classify(row, err);
      if (!outcome.retry || attempt >= MAX_ATTEMPTS) return outcome;
      const backoff = outcome.retryAfterMs ?? 1000 * 2 ** attempt + Math.random() * 1000;
      await new Promise((resolve) => setTimeout(resolve, Math.min(backoff, 60_000)));
    }
  }
}

async function classify(row, err) {
  if (!(err instanceof WebPushError)) {
    // DNS failure, TLS error, socket timeout: transient.
    return { ok: false, retry: true, reason: err.code ?? err.message };
  }
  const status = err.statusCode;
  if (status === 404 || status === 410) {
    // Expired or unsubscribed. Never send to this endpoint again.
    await pool.query("DELETE FROM push_subscriptions WHERE id = $1", [row.id]);
    return { ok: false, retry: false, gone: true, status };
  }
  if (status === 429 || status >= 500) {
    return { ok: false, retry: true, status, retryAfterMs: parseRetryAfter(err.headers?.["retry-after"]) };
  }
  if (status === 401 || status === 403) {
    // Bad or expired JWT, clock skew, or the subscription was created with a
    // different applicationServerKey. Drop the cached header and record it.
    vapidHeaderCache.delete(new URL(row.endpoint).origin);
  }
  // 400, 401, 403, 413 and anything else: our request is wrong; retrying will not help.
  await pool.query(
    "UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = $1",
    [row.id],
  );
  return { ok: false, retry: false, status, reason: String(err.body).slice(0, 500) };
}

function parseRetryAfter(value) {
  if (!value) return undefined;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return seconds * 1000;
  const date = Date.parse(value); // HTTP-date form
  return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
}

/** Fan out one message to every device of a user, with bounded concurrency. */
export async function sendToUser(userId, message, options) {
  const { rows } = await pool.query(
    "SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1",
    [userId],
  );
  const results = [];
  const queue = [...rows];
  const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
    for (let row = queue.shift(); row; row = queue.shift()) {
      results.push({ id: row.id, ...(await sendToSubscription(row, message, options)) });
    }
  });
  await Promise.all(workers);
  return results;
}

Calling it from application code looks like this. The payload uses the Declarative Web Push format, which the service worker below understands in every browser:

server/notify-reply.js
import { sendToUser } from "./push-sender.js";

export async function notifyReply({ recipientId, thread, reply }) {
  const results = await sendToUser(
    recipientId,
    {
      web_push: 8030,
      notification: {
        title: `${reply.authorName} replied`,
        body: reply.excerpt.slice(0, 140),
        navigate: `https://app.example.com/threads/${thread.id}#reply-${reply.id}`,
        tag: `thread-${thread.id}`, // one notification per thread on the device
        data: { threadId: thread.id, replyId: reply.id },
      },
    },
    { ttl: 24 * 60 * 60, urgency: "normal", topic: `thread-${thread.id}` },
  );
  const delivered = results.filter((r) => r.ok).length;
  const pruned = results.filter((r) => r.gone).length;
  console.info({ recipientId, delivered, pruned, total: results.length });
}

For large audiences, do not fan out inside a web request. Put one job per recipient (or per subscription) on a queue, let workers call sendToSubscription(), and keep separate rate limits per push service so that a 429 from one service does not stall delivery to the others. Reuse connections: pass a keep-alive https.Agent through the agent option, or send the output of generateRequestDetails() through an HTTP/2 client. Apple's documentation allows HTTP/1.1 and HTTP/2, asks you not to exceed 100 unacknowledged pipelined requests on an HTTP/1.1 connection, and asks you to respect the server's SETTINGS_MAX_CONCURRENT_STREAMS on HTTP/2.

Python with pywebpush

pywebpush (2.5.0 in August 2026, Python 3.10 or later) offers a one-call webpush() function, a WebPusher class for repeated sends, and webpush_async() built on aiohttp. Two of its defaults need attention:

  • ttl defaults to 0. A message sent without an explicit TTL is dropped by the push service if the device is not connected at that instant. Always pass ttl.
  • vapid_claims is mutated. If the dict has no aud, webpush() fills it in from the first endpoint it sees and, if exp is missing or past, sets it to 12 hours ahead. Reusing one module-level claims dict therefore sends the FCM audience to Mozilla or Apple on later calls, which fails with 401/403. Build a fresh dict per call, or sign the header yourself as below.

WebPushException carries the underlying requests response in .response (current releases also expose .status_code and .retry_after shortcuts). This sender mirrors the Node.js version, including per-audience header caching through py_vapid, which pywebpush depends on:

push_sender.py
"""push_sender.py: send web push messages with pywebpush and prune dead subscriptions."""

from __future__ import annotations

import json
import os
import random
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from urllib.parse import urlsplit

import psycopg
import requests
from psycopg.rows import dict_row
from py_vapid import Vapid
from pywebpush import WebPushException, webpush

VAPID_SUBJECT = os.environ["VAPID_SUBJECT"]  # "mailto:[email protected]"
VAPID_PRIVATE_KEY = os.environ["VAPID_PRIVATE_KEY"]  # raw base64url (web-push format) or DER
MAX_PLAINTEXT_BYTES = 3993
JWT_LIFETIME_S = 12 * 60 * 60
JWT_REFRESH_MARGIN_S = 60 * 60
MAX_ATTEMPTS = 3

_vapid = Vapid.from_string(VAPID_PRIVATE_KEY)
_header_cache: dict[str, tuple[str, int]] = {}  # audience -> (Authorization, exp)
_session = requests.Session()  # connection reuse matters when fanning out


@dataclass
class SendResult:
    ok: bool
    status: int | None = None
    gone: bool = False
    retry_after: float | None = None
    reason: str | None = None


def _authorization(endpoint: str) -> str:
    parts = urlsplit(endpoint)
    audience = f"{parts.scheme}://{parts.netloc}"
    now = int(time.time())
    cached = _header_cache.get(audience)
    if cached and cached[1] - JWT_REFRESH_MARGIN_S > now:
        return cached[0]
    exp = now + JWT_LIFETIME_S
    # Build a fresh claims dict every time: pywebpush's webpush() mutates the
    # dict you pass (it fills in "aud"), which breaks reuse across push services.
    header = _vapid.sign({"sub": VAPID_SUBJECT, "aud": audience, "exp": exp})["Authorization"]
    _header_cache[audience] = (header, exp)
    return header


def _retry_after_seconds(value: str | None) -> float | None:
    if not value:
        return None
    try:
        return float(value)
    except ValueError:
        try:
            return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
        except (TypeError, ValueError):
            return None


def send_to_subscription(
    conn: psycopg.Connection,
    row: dict,
    message: dict | str,
    *,
    ttl: int = 3600,
    urgency: str = "normal",
    topic: str | None = None,
) -> SendResult:
    payload = message if isinstance(message, str) else json.dumps(message, separators=(",", ":"))
    size = len(payload.encode("utf-8"))
    if size > MAX_PLAINTEXT_BYTES:
        raise ValueError(f"push payload is {size} bytes, limit {MAX_PLAINTEXT_BYTES}")

    subscription_info = {
        "endpoint": row["endpoint"],
        "keys": {"p256dh": row["p256dh"], "auth": row["auth"]},
    }
    headers = {"Urgency": urgency}
    if topic:
        headers["Topic"] = topic  # at most 32 base64url characters

    for attempt in range(1, MAX_ATTEMPTS + 1):
        headers["Authorization"] = _authorization(row["endpoint"])
        try:
            response = webpush(
                subscription_info,
                data=payload,
                headers=headers,
                ttl=ttl,  # pywebpush defaults to 0: always pass it explicitly
                timeout=10,
                requests_session=_session,
            )
            conn.execute(
                "UPDATE push_subscriptions SET last_success_at = now(), failure_count = 0 WHERE id = %s",
                (row["id"],),
            )
            return SendResult(ok=True, status=response.status_code)
        except WebPushException as exc:
            status = exc.response.status_code if exc.response is not None else None
            if status in (404, 410):
                conn.execute("DELETE FROM push_subscriptions WHERE id = %s", (row["id"],))
                return SendResult(ok=False, status=status, gone=True)
            if status is None or status == 429 or status >= 500:
                wait = _retry_after_seconds(
                    exc.response.headers.get("Retry-After") if exc.response is not None else None
                )
                if attempt == MAX_ATTEMPTS:
                    return SendResult(ok=False, status=status, retry_after=wait, reason=exc.message)
                time.sleep(min(wait if wait is not None else 2**attempt + random.random(), 60))
                continue
            if status in (401, 403):
                parts = urlsplit(row["endpoint"])
                _header_cache.pop(f"{parts.scheme}://{parts.netloc}", None)
            conn.execute(
                "UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = %s",
                (row["id"],),
            )
            body = exc.response.text[:500] if exc.response is not None else ""
            return SendResult(ok=False, status=status, reason=body)
        except requests.RequestException as exc:  # DNS, TLS, timeout
            if attempt == MAX_ATTEMPTS:
                return SendResult(ok=False, reason=str(exc))
            time.sleep(2**attempt + random.random())
    return SendResult(ok=False, reason="unreachable")


def send_to_user(conn: psycopg.Connection, user_id: int, message: dict, **options) -> list[SendResult]:
    with conn.cursor(row_factory=dict_row) as cur:
        cur.execute(
            "SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = %s",
            (user_id,),
        )
        rows = cur.fetchall()
    return [send_to_subscription(conn, row, message, **options) for row in rows]

py_vapid's Vapid.from_string() accepts either a raw 32-byte key in base64url (the format web-push generates) or a DER-encoded key, so both stacks can share one key pair.

Quick one-off sends for testing

When you only need to fire a message at one subscription, for example while developing the service worker:

Terminal
npx web-push send-notification \
  --endpoint="https://fcm.googleapis.com/fcm/send/dJ1u0..." \
  --key="BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4..." \
  --auth="tBHItJI5svbpez7KI4CCXg" \
  --payload='{"title":"Test","body":"Hello from the CLI","url":"/inbox"}' \
  --ttl=60 \
  --vapid-subject="mailto:[email protected]" \
  --vapid-pubkey="$VAPID_PUBLIC_KEY" \
  --vapid-pvtkey="$VAPID_PRIVATE_KEY"
send-once.mjs
import webpush from "web-push";
import { readFile } from "node:fs/promises";

const subscription = JSON.parse(await readFile("subscription.json", "utf8"));
webpush.setVapidDetails(
  "mailto:[email protected]",
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);
try {
  const res = await webpush.sendNotification(
    subscription,
    JSON.stringify({ title: "Test", body: "Hello from Node.js", url: "/inbox" }),
    { TTL: 60, urgency: "high" },
  );
  console.log(res.statusCode); // 201
} catch (err) {
  console.error(err.statusCode, err.body); // WebPushError
}
send_once.py
import json
import os

from pywebpush import WebPushException, webpush

with open("subscription.json") as f:
    subscription = json.load(f)

try:
    response = webpush(
        subscription,
        data=json.dumps({"title": "Test", "body": "Hello from Python", "url": "/inbox"}),
        vapid_private_key=os.environ["VAPID_PRIVATE_KEY"],
        vapid_claims={"sub": "mailto:[email protected]"},  # fresh dict per call
        ttl=60,
        headers={"Urgency": "high"},
    )
    print(response.status_code)  # 201
except WebPushException as exc:
    print(exc.response.status_code if exc.response is not None else None, exc)

Libraries for other languages

The web-push-libs organization maintains most of the libraries below. All of them implement aes128gcm and VAPID; check the defaults noted here, because they differ.

Language Package Notes
Go github.com/SherClockHolmes/webpush-go (v1.4.0) SendNotification(message, subscription, *Options). TTL defaults to 0 (Go zero value). Pads every message to a full 4,096-byte record by default (RecordSize changes it), which hides payload length from the push service
PHP minishlink/web-push (v11.0.0, PHP 8.2+) queueNotification() plus flush() for batches, flushPooled() for concurrent sends through an async HTTP client, setReuseVAPIDHeaders(true) to reuse JWTs; reports expose isSubscriptionExpired()
Java nl.martijndwars:web-push (5.1.2) Depends on Bouncy Castle as the JCE provider; PushService for blocking calls, PushAsyncService for non-blocking ones
C# / .NET WebPush on NuGet (1.0.13) WebPushClient.SendNotificationAsync(subscription, payload, vapidDetails)
Rust web-push crate (0.11.0) WebPushMessageBuilder and VapidSignatureBuilder, sent through an async client such as IsahcWebPushClient

A minimal Go sender, for comparison:

send.go
package main

import (
    "encoding/json"
    "io"
    "log"
    "os"

    webpush "github.com/SherClockHolmes/webpush-go"
)

func main() {
    raw, err := os.ReadFile("subscription.json")
    if err != nil {
        log.Fatal(err)
    }
    var sub webpush.Subscription
    if err := json.Unmarshal(raw, &sub); err != nil {
        log.Fatal(err)
    }
    resp, err := webpush.SendNotification([]byte(`{"title":"Test","url":"/inbox"}`), &sub, &webpush.Options{
        Subscriber:      "[email protected]", // "mailto:" is added unless it starts with https:
        VAPIDPublicKey:  os.Getenv("VAPID_PUBLIC_KEY"),
        VAPIDPrivateKey: os.Getenv("VAPID_PRIVATE_KEY"),
        TTL:             3600, // the zero value would mean "deliver now or drop"
        Urgency:         webpush.UrgencyNormal,
        Topic:           "inbox-count",
    })
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    log.Printf("status=%d body=%s", resp.StatusCode, body) // 404/410: delete the subscription
}

Unlike web-push and pywebpush, the Go library does not turn non-2xx responses into errors: check resp.StatusCode yourself.

Handling push service responses

RFC 8030 defines the core status codes, and each push service adds its own details in the body. Build your error handling around the status code and log the body for diagnosis:

Status Meaning What to do
201 Created Accepted for delivery (not yet delivered). A Location header identifies the stored message Record success
202 Accepted Accepted with a delivery receipt requested (Prefer: respond-async) Rarely seen; major services do not offer receipts
400 Bad Request Malformed request: missing TTL, invalid Topic, several Urgency values, bad encryption headers Fix the sender; do not retry
401 Unauthorized / 403 Forbidden VAPID failure: bad signature, expired or too-distant exp, wrong aud, or a key that does not match the subscription's applicationServerKey Check clock, audience and key ID. Do not delete the subscription on the first occurrence
404 Not Found The subscription expired or the endpoint is invalid Delete the subscription
410 Gone The subscription was unsubscribed or is permanently unavailable Delete the subscription
413 Payload Too Large Body over the service's limit (at least 4,096 bytes must be accepted) Shrink the payload; do not retry
429 Too Many Requests Rate limited Wait for Retry-After, then retry with backoff
500, 502, 503 Push service or upstream failure Retry with exponential backoff and jitter

Service-specific details you will run into:

  • Mozilla autopush returns a JSON body with code, errno, error and message. Useful errno values: 102 invalid endpoint and 103 expired endpoint (404/410), 104 payload too large, 109 invalid authentication (401), 112 invalid TTL (a malformed value; a TTL above autopush's 2,592,000-second maximum is capped to that maximum rather than rejected), 113 invalid Topic, and on 503 201 means "use exponential backoff" while 202 means an immediate retry is fine. Its 429 responses carry a Retry-After with deliberate jitter near 120 seconds that you should honor as given.
  • Apple's push service returns an apns-id header identifying each request and, on errors, a JSON body with a reason key. Its status table lists 403 for authentication errors, 410 for an expired device token, 413 for oversized payloads and 429 for "too many requests for the same destination".
  • FCM answers a VAPID key that does not match the one the subscription was created with with 403, and uses 404/410 for subscriptions that no longer exist.

A 403 on every message to one push service usually means a configuration error (wrong private key, wrong subject format, skewed clock), not dead subscriptions. Deleting subscriptions on 403 would wipe your whole audience after a bad deploy, which is why the senders above only count those failures. Prune rows whose failure_count keeps rising over days while last_seen_at is old.

Payload size, TTL, Urgency and Topic

Payload size: 4,096 bytes on the wire, 3,993 bytes for you

RFC 8030 forbids push services from rejecting a body of 4,096 bytes or less, so 4,096 is the portable ceiling. RFC 8291 then requires the whole payload to be encrypted as a single aes128gcm record, which costs 86 bytes of header (16-byte salt, 4-byte record size, 1-byte key ID length, 65-byte ephemeral public key), at least 1 byte of padding delimiter, and a 16-byte authentication tag. The RFC does the arithmetic: at most 3,993 bytes of plaintext. With web-push, a 3,993-byte payload encrypts to exactly 4,096 bytes and a 3,994-byte payload to 4,097. Measure bytes of UTF-8, not JavaScript string length; an emoji is four bytes.

Some delivery paths are tighter. Mozilla's autopush documentation warns that bridged delivery, such as through FCM, requires transcoding and may limit data to 2,744 bytes instead of 4,096; Firefox for Android receives pushes that way. If you target Firefox on Android, keep bodies under that figure.

Design payloads for this budget:

  • Send identifiers and display text, not documents. A title, a short body, a URL, and an ID the service worker can use to fetch more is plenty.
  • If you need more, send a small "tickle" (an empty body or { "fetch": true, "id": "..." }) and let the service worker fetch the content with the user's credentials. The service worker code below does this with a timeout and a fallback, because the notification must appear even if the fetch fails.
  • Never compress the payload yourself. RFC 8291 forbids content codings other than aes128gcm because compression before encryption can leak content.
  • An empty push (no body) is valid and needs no encryption headers, but the service worker still has to show a notification.

TTL: how long the push service keeps trying

TTL is required and is the number of seconds the push service may store an undelivered message. After it elapses, the message is discarded without any signal to you. A TTL of 0 means "deliver now if the device is connected, otherwise drop"; RFC 8030 also warns that zero-TTL messages may be removed immediately after delivery, so you cannot rely on receipts for them.

Push services cap the value and may apply a lower one, which they report in the response's TTL header:

Push service or library Maximum or default
FCM 0 to 2,419,200 seconds (28 days); four weeks when unspecified
Mozilla autopush 0 to 2,592,000 seconds (about 30 days); larger values are capped
Apple push service Stores undelivered notifications for 30 days or fewer, and limits how many it stores per offline device
web-push (Node.js) Default 2419200 (4 weeks)
pywebpush Default 0
webpush-go Default 0

Choose TTL from the message's useful life, not from a global constant:

Message Suggested TTL Why
Incoming call, 2FA prompt, "your ride is here" 0 to 60 seconds Worthless moments later
Chat message, mention, reply Hours to 1 day Still useful when the phone comes back online
Price alert, flight gate change Until the event time Compute eventTime - now
Weekly digest, content update 1 to 7 days Low urgency; combine with Topic so only the latest survives

Urgency: letting the device save battery

Urgency tells the push service how important a message is so that it can hold low-priority messages until the device is in a good state. RFC 8030 describes the values with this illustrative table:

Urgency Device state in which it is delivered Example
very-low On power and Wi-Fi Advertisements
low On either power or Wi-Fi Topic updates
normal (default) On neither power nor Wi-Fi Chat or calendar message
high Low battery Incoming phone call or time-sensitive alert

The push service does not forward the header to the browser. The browser can in turn tell the push service the lowest urgency it is currently willing to receive, which is how a device on low battery avoids waking up for trivia. On Android, FCM distinguishes normal from high priority: its documentation says normal-priority messages may be delayed while the device is in Doze mode, while high-priority messages are delivered immediately and can wake the device. Use high only for messages the user would want to be interrupted for; overusing it drains batteries and trains users to disable notifications.

Topic: replacing messages the device has not received yet

A Topic makes a stored message replaceable. If the push service still holds an undelivered message with the same topic for the same subscription, the new message replaces it, including its TTL and Urgency. When the device reconnects, it receives only the latest version. This is ideal for state that supersedes itself: an unread counter, a score, the latest status of an order.

Topics are at most 32 characters from the base64url alphabet (A-Z, a-z, 0-9, -, _), are not encrypted, and are not forwarded to the browser. Keep them opaque (order-8f3a, not [email protected]).

Topic works on the push service, before delivery. The notification tag option works on the device, after delivery: a notification with the same tag replaces the one already on screen. You usually want both, set to related values. Note that Safari accepts tag but does not use it to replace notifications: MDN's compatibility data records the option as having no effect, and WebKit bug 258922 ("Push notifications with same tag do not replace each other") is still open. On Apple platforms, only Topic reliably coalesces messages; to replace a visible notification in Safari, look it up with registration.getNotifications() and close() it before showing the new one.

Receiving pushes in the service worker

The push event and PushMessageData

When a message arrives, the browser decrypts it, starts your service worker if it is not running, and dispatches a push event. It is a functional event: it is the reason the worker was started, and the worker lives only as long as the promise you pass to event.waitUntil() keeps it alive (subject to each engine's per-event limits, described in Service Workers).

Push API (excerpt)
[Exposed=ServiceWorker, SecureContext]
interface PushEvent : ExtendableEvent {
  readonly attribute PushMessageData? data;        // null for an empty push
  readonly attribute Notification? notification;   // Declarative Web Push only
};

[Exposed=ServiceWorker, SecureContext]
interface PushMessageData {
  ArrayBuffer arrayBuffer();
  Blob blob();
  Uint8Array bytes();
  any json();
  USVString text();
};

PushMessageData is not a stream: every method reads the same decrypted bytes, and you can call several of them on one message. json() throws a SyntaxError for invalid JSON, so wrap it and fall back to text(). bytes() is the newest method (Chrome 132, Firefox 128, Safari 18). The payload was decrypted before your code runs, and a message that fails to decrypt never produces an event at all.

The user-visible requirement and silent-push penalties

With userVisibleOnly: true you promised that each push produces something visible. The Push API tracks whether showNotification() was successfully invoked during each push event, and each engine enforces the promise differently:

Engine When no notification is needed Tolerance Consequence of a miss
Chrome While an active, visible tab of the origin is open in a non-minimized window A budget based on site engagement: a silent push costs 2 points, a fully engaged site earns at most 12 points a day (about six silent pushes), and budget expires after 4 days Chrome shows its own notification, "This site has been updated in the background"
Microsoft Edge Not documented Not documented Edge "displays a generic notification that indicates that a push message was received"
Firefox While a tab of the site is open A quota of 16 pushes (dom.push.maxQuotaPerSubscription). Three seconds after each push, Firefox deducts one unless a notification from the origin is showing. When a push arrives after the user has visited the site, the quota is recomputed from how long ago that visit was: about ten hours or less restores all 16, one day gives 8, a week about 2 At zero the subscription is expired and unsubscribed in the background; after the user visits the site again, Firefox fires pushsubscriptionchange during its daily idle maintenance
Safari (macOS, iOS, iPadOS) Never, except for Declarative Web Push messages showNotification() must be called within 30 seconds of the push. A worker being inspected in Web Inspector is exempt Each miss increments a per-origin counter; at 3, WebKit removes all of the origin's push subscriptions. Apple's documentation says Safari revokes the push permission

The Chrome figures come from budget_database.cc, the Firefox figures from PushRecord.sys.mjs and all.js, and the WebKit figures from WebPushDaemonConstants.h and NotificationData.h. They are implementation details that can change, but the direction is stable: every push must end in showNotification(), and that call must complete before the promise passed to waitUntil() settles.

Practical consequences:

  • Do not skip the notification when a window is open. Chromium exempts a visible tab, but Firefox only exempts an open tab, and Safari exempts nothing. Post a message to the open window and show the notification, optionally with silent: true.
  • Always have a fallback. If your handler fetches content and the fetch fails, show a generic notification rather than nothing. A bug that throws before showNotification() costs you subscriptions on Safari.
  • Never use push for background sync. "Wake up and refresh the cache" without a notification is exactly what the penalties exist to stop. Use Background Sync or Periodic Background Sync where supported.

A production push handler

This service worker section accepts four kinds of message: the Declarative Web Push JSON format, your own JSON, plain text, and empty "tickles" that tell the worker to fetch the content. It always ends in exactly one notification.

sw.js (push section)
const APP_ORIGIN = self.location.origin;
const FALLBACK = {
  title: "New activity",
  body: "Open the app to see what changed.",
  url: "/",
};
const FETCH_TIMEOUT_MS = 8000; // well inside every engine's event budget

self.addEventListener("push", (event) => {
  event.waitUntil(handlePush(event));
});

async function handlePush(event) {
  // Declarative Web Push with "mutable": true (Safari 18.4+). The browser already
  // built a notification. If we do nothing, it is shown as-is.
  if (event.notification) return;

  let spec;
  try {
    const message = parseMessage(event.data);
    spec = message.needsFetch ? await fetchNotificationSpec(message) : message;
  } catch (err) {
    console.error("push: falling back to generic notification", err);
    spec = { title: FALLBACK.title, options: { body: FALLBACK.body, data: { url: FALLBACK.url } } };
  }

  // Tell any open window, but still show the notification: Safari and Firefox
  // penalize pushes that end without one, even while a window is open.
  const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
  for (const client of windows) client.postMessage({ type: "push", payload: spec.options.data });

  await self.registration.showNotification(spec.title, spec.options);

  if (typeof spec.badgeCount === "number" && "setAppBadge" in self.navigator) {
    await self.navigator.setAppBadge(spec.badgeCount).catch(() => {});
  }
}

/** Accepts declarative JSON (web_push: 8030), the app's own JSON, text, or no payload. */
function parseMessage(data) {
  if (!data) return { needsFetch: true }; // a "tickle": fetch the content
  let json;
  try {
    json = data.json();
  } catch {
    return build({ title: FALLBACK.title, body: data.text(), url: FALLBACK.url });
  }
  if (json.web_push === 8030 && json.notification) {
    const n = json.notification;
    return build({
      title: n.title,
      body: n.body,
      url: n.navigate,
      tag: n.tag,
      icon: n.icon,
      badge: n.badge,
      image: n.image,
      actions: n.actions,
      requireInteraction: n.requireInteraction,
      silent: n.silent,
      timestamp: n.timestamp,
      extra: n.data,
      badgeCount: json.app_badge ?? n.app_badge,
    });
  }
  if (json.fetch === true) return { needsFetch: true, id: json.id };
  return build(json);
}

function build(input) {
  const url = new URL(input.url ?? FALLBACK.url, APP_ORIGIN).href;
  // Notification.maxActions is undefined where actions are unsupported (Safari).
  // Guard the global too, so a missing interface cannot throw before showNotification().
  const maxActions = typeof Notification === "undefined" ? 0 : Notification.maxActions ?? 0;
  const actions = (input.actions ?? []).slice(0, maxActions);
  const actionUrls = Object.fromEntries(
    actions
      .filter((a) => a.url ?? a.navigate)
      .map((a) => [a.action, new URL(a.url ?? a.navigate, APP_ORIGIN).href]),
  );
  const options = {
    body: input.body ?? "",
    icon: input.icon ?? "/icons/icon-192.png",
    badge: input.badge ?? "/icons/badge-96.png", // monochrome, used by Android's status bar
    tag: input.tag, // same tag replaces the previous notification (not in Safari)
    renotify: Boolean(input.tag && input.renotify), // Chromium only; requires a tag
    requireInteraction: Boolean(input.requireInteraction),
    silent: Boolean(input.silent),
    timestamp: input.timestamp,
    image: input.image,
    actions: actions.map(({ action, title, icon }) => ({ action, title, icon })),
    data: { ...(input.extra ?? {}), url, id: input.id, actions: actionUrls },
  };
  for (const key of Object.keys(options)) if (options[key] === undefined) delete options[key];
  const badgeCount = input.badgeCount === undefined ? undefined : Number(input.badgeCount);
  return { title: input.title || FALLBACK.title, options, badgeCount };
}

async function fetchNotificationSpec(message) {
  const url = new URL("/api/notifications/latest", APP_ORIGIN);
  if (message.id) url.searchParams.set("id", message.id);
  const res = await fetch(url, {
    credentials: "same-origin",
    headers: { Accept: "application/json" },
    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
  });
  if (!res.ok) throw new Error(`notification fetch failed: ${res.status}`);
  return build(await res.json());
}

Details worth knowing about the options used here (the Notifications API page documents every option and its support):

  • showNotification() rejects with a TypeError if notification permission is not "granted" or the registration has no active worker. Because the promise is part of waitUntil(), a rejection marks the push as failed.
  • renotify: true without a tag throws a TypeError in Chromium, which is why the code only sets it with a tag. Without renotify, a notification that replaces another with the same tag appears silently.
  • badge is the small monochrome icon Android shows in the status bar; icon is the large image. Safari ignores icon, tag and badge and uses the app or site icon.
  • Notification.maxActions reports how many action buttons the platform shows. It is undefined in Safari, which does not support actions; Firefox added actions in version 152.
  • Chrome 152 attributes notifications from installed PWAs on macOS to the app itself, and for those notifications requireInteraction is no longer supported: the user decides per app in macOS settings whether alerts persist.

Declarative Web Push

Declarative Web Push, shipped by Apple in iOS and iPadOS 18.4 and in Safari 18.5 on macOS (Sequoia, Sonoma and Ventura), lets the browser display a notification without running any JavaScript. If a payload is JSON with a top-level "web_push": 8030 member, the browser parses it itself:

Declarative push message
{
  "web_push": 8030,
  "notification": {
    "title": "Ada replied to your comment",
    "body": "Did you hear about the tube strikes?",
    "navigate": "https://app.example.com/threads/42#reply-7",
    "lang": "en-US",
    "dir": "ltr",
    "tag": "thread-42",
    "silent": false,
    "data": { "threadId": 42 },
    "actions": [
      { "action": "open", "title": "Open", "navigate": "https://app.example.com/threads/42" }
    ]
  },
  "mutable": false
}

title and navigate are required and every other member mirrors a NotificationOptions field. When the user activates the notification, the browser navigates to navigate (or to the action's navigate) directly; no notificationclick event fires for a notification that has a navigation URL. WebKit's announcement example also includes an app_badge member for Home Screen web apps; the Push API draft's member list does not include it yet.

With "mutable": true and a service worker present, the browser dispatches a normal push event whose event.notification holds the proposed notification. If your handler shows a replacement notification, it wins; if the handler does nothing or fails, the proposed notification is displayed. That is why declarative messages are exempt from Safari's silent-push penalty. It also survives the loss of the service worker: Safari's tracking prevention can delete the website data of sites the user has not interacted with recently, including service worker registrations, and a declarative message still displays.

Declarative Web Push also adds window.pushManager, so a page can subscribe without registering a service worker. A subscription made there is shared with a service worker registered at the origin's root scope, and unregistering that worker does not remove it. Feature-detect it with "pushManager" in window.

Firefox has an implementation behind the dom.push.declarative.enabled preference (off by default), and Chromium has not shipped it. You can still adopt the format everywhere today: send declarative JSON from the server, and handle it in your service worker, as parseMessage() above does. Safari then displays it natively and other browsers display it through your code, with one payload format for all of them. Web Push on iOS & Safari covers the Apple-specific parts in depth.

Handling notification clicks

A click on a notification shown by your service worker fires notificationclick on the worker (unless the notification has a declarative navigate URL). The event is a NotificationEvent with the notification (including its data) and action, the identifier of the action button that was clicked, or the empty string for the body.

The browser gives the handler a short window in which it may open or focus windows. The service worker spec rejects clients.openWindow() and WindowClient.focus() with InvalidAccessError unless a window of the origin has transient user activation, and browsers treat the notification click as granting it. Firefox, for example, allows this for 1,000 ms after the click on desktop and 5,000 ms on Android (dom.webnotifications.disable_open_click_delay). Do the window work first and any network work afterwards.

sw.js (click section)
self.addEventListener("notificationclick", (event) => {
  const { notification, action } = event;
  notification.close(); // Android and some desktops do not close it automatically
  const data = notification.data ?? {};
  const actionUrl = action && data.actions ? data.actions[action] : undefined;
  const target = new URL(actionUrl ?? data.url ?? "/", APP_ORIGIN);
  event.waitUntil(openOrFocus(target, { action, id: data.id }));
});

async function openOrFocus(target, detail) {
  const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
  // 1. A window already showing the exact URL: just focus it.
  const exact = windows.find((c) => c.url === target.href);
  if (exact) {
    exact.postMessage({ type: "notification-click", ...detail });
    return exact.focus();
  }
  // 2. Any window of this app: focus it, then navigate or ask the page to route.
  const sameOrigin = windows.find((c) => new URL(c.url).origin === target.origin);
  if (sameOrigin) {
    const focused = await sameOrigin.focus();
    // navigate() only works for clients this worker controls.
    if ("navigate" in focused && focused.url !== target.href) {
      try {
        return await focused.navigate(target.href);
      } catch {
        focused.postMessage({ type: "navigate", url: target.href, ...detail });
        return focused;
      }
    }
    return focused;
  }
  // 3. Nothing open: open a new window (inside the installed app window where supported).
  return self.clients.openWindow(target.href);
}

self.addEventListener("notificationclose", (event) => {
  const id = event.notification.data?.id;
  if (!id) return;
  event.waitUntil(
    fetch("/api/notifications/dismissed", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id }),
    }).catch(() => {}),
  );
});

Why each step is there:

  • includeUncontrolled: true finds windows that are not controlled by this worker, such as a tab that was open before the worker activated or one loaded with a hard reload. Without it you open a duplicate window.
  • Focus before navigating. WindowClient.navigate() rejects with a TypeError for windows the worker does not control, and it is a full navigation. For a single-page app, a postMessage() that the page turns into a client-side route change is often better; the page code listens on navigator.serviceWorker, as described in Messaging & the Clients API.
  • openWindow() details. It rejects with a TypeError for an invalid URL or about:blank, and resolves with null rather than a client when the new window ends up on a different origin. In Chromium, a URL within the scope of an installed PWA opens in the app's window instead of a browser tab.
  • notification.close() is explicit because platforms differ in whether a click dismisses the notification.
  • notificationclose fires when the user dismisses the notification without clicking. It is the only signal you get for "seen and ignored", so it is worth recording for tuning what you send.

Keeping subscriptions fresh: pushsubscriptionchange

The spec defines pushsubscriptionchange for subscription changes "triggered outside of the application's control":

  • Refresh. The browser or push service rotates the subscription (for example, because of its age). The event carries oldSubscription and newSubscription, and the old endpoint may keep working briefly.
  • Expiry or loss. The subscription can no longer be used. newSubscription is null.
  • Permission revoked. The browser may fire the event with newSubscription set to null, and must deactivate the subscription.

Browser behavior is uneven, which is why this event cannot be your only mechanism:

Browser Behavior
Chrome and Edge 138+ Fire the event when notification permission is granted again after having been revoked, with both oldSubscription and newSubscription set to null. Firing on invalidation exists in the code base but is disabled by default
Firefox Fires the event, for example after the quota-expiry case above once the user returns. The oldSubscription and newSubscription properties are available from Firefox 137; earlier versions fired the event without them
Safari Implements the event and PushSubscriptionChangeEvent on macOS (Safari 16). Not available on iOS and iPadOS (MDN: not supported in Safari on iOS)

The handler in the service worker must therefore cope with oldSubscription and newSubscription both being null, create a subscription itself when needed, and report the change to the server. It runs without any page, so it cannot show UI, and it authenticates with the origin's cookies:

sw.js (subscription change section)
self.addEventListener("pushsubscriptionchange", (event) => {
  event.waitUntil(resubscribe(event.oldSubscription, event.newSubscription));
});

async function resubscribe(oldSubscription, newSubscription) {
  // Chrome 138+ fires this after notification permission is re-granted, with BOTH
  // values null. Firefox before 137 fired it without the properties at all.
  let subscription = newSubscription ?? (await self.registration.pushManager.getSubscription());
  let keyId = null; // null: the server keeps the key ID recorded for the old endpoint
  if (!subscription) {
    const keyRes = await fetch("/api/push/vapid-public-key", { headers: { Accept: "application/json" } });
    if (!keyRes.ok) throw new Error(`VAPID key request failed: ${keyRes.status}`);
    const key = await keyRes.json();
    keyId = key.keyId;
    // Allowed without a user gesture here only because permission is already granted.
    subscription = await self.registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: base64UrlToBytes(key.publicKey),
    });
  }
  const res = await fetch("/api/push/subscriptions/replace", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    body: JSON.stringify({
      oldEndpoint: oldSubscription?.endpoint ?? null,
      keyId,
      subscription: subscription.toJSON(),
    }),
  });
  if (!res.ok) throw new Error(`replace failed: ${res.status}`); // next page load re-syncs
}

function base64UrlToBytes(value) {
  const padded = value + "=".repeat((4 - (value.length % 4)) % 4);
  const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
  return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

The spec suggests using Background Sync to deliver the new subscription reliably, since the device may be offline when the event fires. That only helps in Chromium. The robust design uses three layers:

  1. Re-sync on every page load (initPush() above): the page reads getSubscription() and upserts it. This repairs every missed change the next time the user opens the app.
  2. Handle pushsubscriptionchange where it fires, so changes propagate even if the user does not open the app.
  3. Prune on 404/410 at send time, so dead endpoints disappear from your database.

If your app authenticates API calls with a bearer token held in page memory, the worker cannot call /replace on its own. Either use a cookie-based session for these endpoints or hand the worker a token through IndexedDB, as described in Messaging & the Clients API.

Unsubscribing

subscription.unsubscribe() deactivates the subscription and resolves with true, or with false if it was already deactivated. The browser tells the push service to delete the endpoint and, per the spec, should retry that request for a while if the network is down. After deactivation, the browser delivers no further messages for it, and the endpoint is never reused.

Other ways a subscription ends, and how your server finds out:

Event Subscription How the server learns
Your code calls unsubscribe() Deactivated Your DELETE request (send it first)
The service worker registration is unregistered Deactivated (the spec requires it) 404/410 on the next send
The user clears site data Removed with the registration 404/410 on the next send
The user blocks notifications in browser settings Deactivated 404/410; Chrome 138+ fires pushsubscriptionchange if permission is granted again
Firefox quota exhausted, Safari silent-push strikes Removed 404/410; later pushsubscriptionchange in Firefox
Push service expires it Deactivated 404/410

Keep "can reach this device" separate from "wants these messages". If a user turns off reply notifications in their account settings on another device, do not delete the subscriptions: store the preference per user (and per channel) and have the sender check it. Otherwise the next page load's re-sync silently recreates a subscription row the user thought they had disabled.

Testing and debugging push

Chrome and Edge DevTools

  • Application → Service workers has a Push field, pre-filled with "Test push message from DevTools". Clicking Push dispatches a push event with that text as event.data, directly to the worker. It bypasses the push service and encryption, so it tests your handler, not your server. Put a JSON payload in the field to exercise parseMessage().
  • Application → Background services → Push messaging and Notifications record events for three days, even while DevTools is closed. Turn recording on, close DevTools, send real pushes from your server, then come back to see what arrived and which notifications were shown.
  • The Stop link in the service worker pane (or chrome://serviceworker-internals) terminates the worker so you can test cold starts. Remember that an attached DevTools session keeps the worker alive, which can hide timing bugs.
  • In automated tests, the Chrome DevTools Protocol command ServiceWorker.deliverPushMessage (parameters origin, registrationId, data) does the same as the Push button, and Browser.grantPermissions with notifications avoids the prompt. See Automated Testing.

Firefox and Safari

  • Firefox's about:debugging#/runtime/this-firefox lists the registered service workers and has a Push button to fire a test push event at a worker.
  • Safari 26 added Automatically Inspect New Service Workers and Automatically Pause New Service Workers to the Develop menu's Inspect Apps and Devices tool, which opens Web Inspector the moment a worker starts to handle a push. This matters because on Apple platforms the push has usually been handled by the time you could attach manually. An inspected worker does not count toward the silent-push limit.

End-to-end testing

The browser-side tools above never touch the push service. To test the real path:

  1. Run your app on http://localhost (Chromium) or on HTTPS, subscribe, and copy the subscription JSON from the network request to your server.
  2. Send to it with the CLI or the one-off scripts above. A 201 proves the VAPID setup; the notification proves encryption and the service worker.
  3. Test with the browser closed (on Android, swipe the app away) and with the device offline, then online, to see TTL and Topic in action.
  4. For iOS and iPadOS you need HTTPS with a valid certificate on the device and an app added to the Home Screen; a tunneling service in front of your development server works. Do not use https://localhost as your VAPID subject, because Apple rejects it.

Browser DevTools covers the tooling in more depth.

Browser support

Feature Chrome / Edge Chrome Android Firefox Firefox Android Safari macOS Safari iOS / iPadOS
Push API (PushManager, push event) ✅ 42 / 17 ✅ 42 ✅ 44 ✅ 48 ✅ 16.1 (macOS 13 Ventura; MDN lists 16) ⚠️ 16.4
Payloads (PushEvent.data) ✅ 50 / 17 ✅ 50 ✅ 44 ✅ 48 ✅ 16 ⚠️ 16.4
PushMessageData.bytes() ✅ 132 ✅ 132 ✅ 128 ✅ 128 ✅ 18 ⚠️ 18
Silent push (userVisibleOnly: false) ❌ ❌ ⚠️ ⚠️ ❌ ❌
pushsubscriptionchange ⚠️ 138 ⚠️ 138 ✅ 44 ✅ 48 ✅ 16 ❌
Declarative Web Push, window.pushManager ❌ ❌ 🧪 ❌ ✅ 18.5 ⚠️ 18.4
Notification action buttons (showNotification() actions) ✅ 48 / 18 ✅ 48 ✅ 152 ✅ 152 ❌ ❌

Support data as of September 2026. Notes:

  • iOS and iPadOS support push only in web apps added to the Home Screen, not in Safari tabs or other browsers' tabs.
  • Firefox accepts userVisibleOnly: false but applies the quota described above, so silent pushes are limited, not free.
  • Chrome and Edge fire pushsubscriptionchange only when notification permission is granted again after a revocation, with both subscription properties null. Firefox added the oldSubscription and newSubscription properties in version 137. The event isn't available on iOS and iPadOS, so Home Screen web apps must re-check the subscription on every launch.
  • Firefox's Declarative Web Push implementation is behind the dom.push.declarative.enabled preference.
  • Samsung Internet supports the Push API from version 4.0. Android WebView does not support it.

For live data, see MDN: Push API and caniuse: Push API.

Best practices

  • Earn the permission. Ask in context, after an action that makes the value obvious, with your own UI first. Track acceptance rates per prompt placement in your analytics.
  • Let users choose what they get. Offer channels (replies, mentions, digests) and quiet hours on your side, and make "turn off" as easy as "turn on". A user who cannot tune notifications will block them at the browser level, which you can never undo.
  • Make every notification actionable and self-contained. Title and body should make sense on a lock screen without context, and the click should land on the exact item, not the home page.
  • Coalesce. Use Topic on the server and tag on the device so that ten messages in a thread become one notification that updates.
  • Set TTL and Urgency per message type instead of using library defaults.
  • Keep the handler fast and fail-safe. Parse defensively, time-box any fetch, and always call showNotification().
  • Re-sync subscriptions on every visit and prune on 404/410; keep failure counters for everything else.
  • Monitor. Record send results by push service and status code, click-through and dismissal rates, and the number of active subscriptions over time. A sudden rise in 403 means a key or clock problem; a steady decline in subscriptions means users are revoking permission.
  • Protect subscriptions like credentials and validate endpoints against an allowlist of push services.

Common pitfalls

Prompting on page load

The prompt appears before the user knows what your app does, most users block it, and the block is permanent. Firefox and Safari additionally reject the request because there is no user gesture. Ask after a meaningful action, from a click.

Awaiting network or serviceWorker.ready before subscribe() in the click handler

Transient activation expires, and Firefox or Safari then reject subscribe() with NotAllowedError even though the user clicked. Resolve the registration and the VAPID key when the page loads.

Doing background work in push without a notification

Chrome and Edge show a generic "updated in the background" notification, Firefox burns its quota and then drops the subscription, and Safari removes the subscription after three misses. Every push must end in showNotification().

Returning before showNotification() resolves

Calling showNotification() without passing its promise to event.waitUntil() (directly or through an async function) lets the worker terminate early, and the push counts as silent. Return one promise chain that includes the call.

Changing the VAPID key on the server only

Existing subscriptions are bound to the old key and start failing with 401/403. Rotate by re-subscribing clients as described above, and keep the old private key until migration completes.

Reusing vapid_claims across calls in pywebpush

webpush() fills in aud from the first endpoint and keeps it, so later sends to other push services carry the wrong audience. Pass a new dict each time or sign headers yourself.

Deleting subscriptions on 403

A 403 usually means your key, subject or clock is wrong, not that the user left. Deleting on 403 turns one configuration error into the loss of your whole audience. Delete only on 404 and 410.

Large payloads

A JSON document with an embedded image URL list, localized strings and tracking data easily passes 4 KB and fails with 413. Send IDs and short text, and fetch the rest.

Relying on pushsubscriptionchange

Chrome only fires it in one narrow case, with no subscription data. Re-sync from the page on every visit.

Sending the previous user's notifications to a shared device

If logout does not unsubscribe or detach the subscription, the next person using the browser receives the previous user's messages. Handle logout explicitly.

Further reading

On this site

External references