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
POSTan encrypted message to the subscription'sendpointURL. - Chrome, Edge and Safari require
userVisibleOnly: trueand a VAPIDapplicationServerKey(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 firstawaitin 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
aes128gcmencryption. Send identifiers or small JSON, not documents. - Always set
TTLdeliberately (library defaults range from 0 seconds to 4 weeks), useUrgencyto respect battery, and useTopicto 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
pushsubscriptionchangeis 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 intoapplicationServerKeyin the browser and into thek=parameter of theAuthorization: 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:
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:
- Publish the new public key with a new key ID from your key endpoint, and keep the old private key on the server.
- Store which key ID each subscription was created with (the
vapid_key_idcolumn below), and sign each message with the matching private key. - On each page load, compare
subscription.options.applicationServerKeywith 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 forsubscribe()may need a button press. - 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:
- 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.
- 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:
[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:
- If the
PushManagerbelongs to aWindow(Declarative Web Push), the scope is the origin's root/. Otherwise the scope is the registration's scope. - If the scope's scheme is not
https, reject withNotAllowedError. (Chromium-based browsers nevertheless allowhttp://localhostduring development.) - If
userVisibleOnlyisfalseand the browser requirestrue, reject withNotAllowedError. - If
applicationServerKeyisnulland the push service requires one, reject withNotSupportedError. - 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 withInvalidAccessError. - In parallel: if the registration has no active worker, reject with
InvalidStateError. Look up the existing subscription for this scope. - Request permission to use
"push". If the result is"denied", reject withNotAllowedError. - If a subscription exists: reject with
AbortErrorif it is in an error state, reject withInvalidStateErrorif its options differ from the ones passed (buffers are compared by content), and otherwise resolve with the existing subscription. - 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 newPushSubscription.
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: 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:
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:
[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):
{
"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:
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 UPDATEhandles 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 senderPOSTto an arbitrary URL, which is a server-side request forgery vector. Also check thatp256dhdecodes to 65 bytes starting with0x04andauthto 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_atandlast_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 returned410. - Handle logout explicitly. Either unsubscribe the browser (the user stops receiving anything) or detach the row from the user (
DELETEit) 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:
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:
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:
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:
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:
ttldefaults to0. A message sent without an explicit TTL is dropped by the push service if the device is not connected at that instant. Always passttl.vapid_claimsis mutated. If the dict has noaud,webpush()fills it in from the first endpoint it sees and, ifexpis 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 with401/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: 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:
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"
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
}
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:
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,errorandmessage. Usefulerrnovalues:102invalid endpoint and103expired endpoint (404/410),104payload too large,109invalid authentication (401),112invalidTTL(a malformed value; aTTLabove autopush's 2,592,000-second maximum is capped to that maximum rather than rejected),113invalidTopic, and on503201means "use exponential backoff" while202means an immediate retry is fine. Its429responses carry aRetry-Afterwith deliberate jitter near 120 seconds that you should honor as given. - Apple's push service returns an
apns-idheader identifying each request and, on errors, a JSON body with areasonkey. Its status table lists403for authentication errors,410for an expired device token,413for oversized payloads and429for "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 uses404/410for 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
aes128gcmbecause 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).
[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.
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 aTypeErrorif notification permission is not"granted"or the registration has no active worker. Because the promise is part ofwaitUntil(), a rejection marks the push as failed.renotify: truewithout atagthrows aTypeErrorin Chromium, which is why the code only sets it with a tag. Withoutrenotify, a notification that replaces another with the same tag appears silently.badgeis the small monochrome icon Android shows in the status bar;iconis the large image. Safari ignoresicon,tagandbadgeand uses the app or site icon.Notification.maxActionsreports 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
requireInteractionis 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:
{
"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.
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: truefinds 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 aTypeErrorfor windows the worker does not control, and it is a full navigation. For a single-page app, apostMessage()that the page turns into a client-side route change is often better; the page code listens onnavigator.serviceWorker, as described in Messaging & the Clients API. openWindow()details. It rejects with aTypeErrorfor an invalid URL orabout:blank, and resolves withnullrather 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.notificationclosefires 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
oldSubscriptionandnewSubscription, and the old endpoint may keep working briefly. - Expiry or loss. The subscription can no longer be used.
newSubscriptionisnull. - Permission revoked. The browser may fire the event with
newSubscriptionset tonull, 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:
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:
- Re-sync on every page load (
initPush()above): the page readsgetSubscription()and upserts it. This repairs every missed change the next time the user opens the app. - Handle
pushsubscriptionchangewhere it fires, so changes propagate even if the user does not open the app. - Prune on
404/410at 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
pushevent with that text asevent.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 exerciseparseMessage(). - 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(parametersorigin,registrationId,data) does the same as the Push button, andBrowser.grantPermissionswithnotificationsavoids the prompt. See Automated Testing.
Firefox and Safari¶
- Firefox's
about:debugging#/runtime/this-firefoxlists 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:
- Run your app on
http://localhost(Chromium) or on HTTPS, subscribe, and copy the subscription JSON from the network request to your server. - Send to it with the CLI or the one-off scripts above. A
201proves the VAPID setup; the notification proves encryption and the service worker. - Test with the browser closed (on Android, swipe the app away) and with the device offline, then online, to see
TTLandTopicin action. - 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://localhostas 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: falsebut applies the quota described above, so silent pushes are limited, not free. - Chrome and Edge fire
pushsubscriptionchangeonly when notification permission is granted again after a revocation, with both subscription propertiesnull. Firefox added theoldSubscriptionandnewSubscriptionproperties 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.enabledpreference. - 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
Topicon the server andtagon the device so that ten messages in a thread become one notification that updates. - Set
TTLandUrgencyper 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
403means 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
- Background & Engagement: all background capabilities and their constraints
- The Web Push Protocol: RFC 8030, 8291 and 8292 at the byte level
- Notifications API: every notification option, permission and platform difference
- Web Push on iOS & Safari: Home Screen requirements, Declarative Web Push and Focus
- Badging API: updating the app icon badge from a push handler
- Messaging & the Clients API:
clients.matchAll(),openWindow(),focus()and page messaging - Permissions: permission states and prompt strategy across capabilities
- Browser DevTools: debugging service workers and background services
External references
- Push API (W3C Editor's Draft), including declarative push messages
- RFC 8030: Generic Event Delivery Using HTTP Push
- RFC 8291: Message Encryption for Web Push
- RFC 8292: Voluntary Application Server Identification (VAPID)
- Notifications API Living Standard
- MDN: PushManager.subscribe()
- MDN: pushsubscriptionchange event
- WebKit: Meet Web Push and Meet Declarative Web Push
- Apple: Sending web push notifications in web apps and browsers
- Mozilla autopush: error codes
- Firebase: Setting the lifespan of a message
- Microsoft Edge: Re-engage users with push messages
- ChromeStatus: pushsubscriptionchange event upon resubscription
- web-push (Node.js) and pywebpush (Python)