Skip to content

Add push notifications

Published

At the end of this guide your server can wake the user’s device with a message while your app is closed: the page subscribes its service worker to the browser’s push service, your server posts to the subscription endpoint, and the worker turns the push event into a system notification. Two APIs are involved. Push is the transport that delivers the message to the worker; the Notifications API is the display the user sees.

You need an active service worker (follow Getting started if you have none), an HTTPS origin, and a VAPID key pair on the server. Support position: the Push API has been available across browsers since 2023-03; Safari delivers it to Home Screen web apps only, from iOS and iPadOS 16.4, and to Safari 16.1 on macOS Ventura. The per-browser rows are in Web Push.

1. Confirm support before offering the button

Section titled “1. Confirm support before offering the button”

ServiceWorkerRegistration.pushManager is the entry point; PushManager is absent on window where the browser has no push. Hide the subscribe control in that case and keep whatever in-app channel you already have (an inbox view, an email digest). On iOS the check also fails in a Safari tab and passes only once the user has added the app to the Home Screen, so the fallback copy should say so.

export function pushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}

Notification.requestPermission() resolves to granted, denied, or default; treat default as a denial. Call it inside the click handler of a button whose label says what the notifications are for. Firefox 72 refuses requests that are not triggered by a user gesture, Safari requires the same direct interaction, and a denied answer is final until the user changes it in browser settings, so one unprompted request on page load can cost you the channel for good.

subscribeButton.addEventListener('click', async () => {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
subscribeButton.disabled = true;
subscribeButton.textContent = 'Notifications are off for this site';
return;
}
await subscribe();
});

Still inside the gesture, call pushManager.subscribe() with userVisibleOnly: true and the server’s VAPID public key as applicationServerKey. Chrome and Edge reject the promise when userVisibleOnly is not true, and they require the key (or a legacy gcm_sender_id in the manifest). The key is an ECDSA P-256 public key, Base64url-encoded on the server; it is not the ECDH key that encrypts payloads.

function base64UrlToUint8Array(base64Url) {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}
async function subscribe() {
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
const subscription =
existing ||
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(VAPID_PUBLIC_KEY),
}));
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
});
}

4. Store the subscription and treat it as expiring

Section titled “4. Store the subscription and treat it as expiring”

subscription.toJSON() yields the endpoint plus the p256dh and auth keys the server needs to encrypt and send. The endpoint is a capability URL: anyone who holds it can push to that user, so store it server-side only and protect the subscribe route against CSRF. Each subscription belongs to one service worker registration, and the push service may set an expiry, in which case the worker receives pushsubscriptionchange; re-subscribe there and update the server.

sw.js
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.registration.pushManager
.subscribe(event.oldSubscription.options)
.then((subscription) =>
fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
}),
),
);
});

The push event carries the decrypted payload in event.data. Because the subscription promised user-visible messages, show a notification for each push; Chrome has no delivery quota, while Firefox limits pushes that do not produce a notification and refreshes the quota on each visit. Handle notificationclick to focus an open window or open a new one.

sw.js
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : { title: 'New message', body: '' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
data: { url: data.url || '/' },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
const open = clients.find((client) => 'focus' in client);
return open ? open.focus() : self.clients.openWindow(event.notification.data.url);
}),
);
});

Before writing server code, open Chrome DevTools, go to Application > Service workers, type a payload into the field next to the Push button and click Push. The worker’s push handler runs with that text; event.data.json() throws on plain text, so test with {"title":"Hi","body":"test"}. A notification appears from the operating system with your icon, and clicking it focuses the app. Then send the same payload from the server with a Web Push library that signs a VAPID JWT and encrypts with the stored keys.

← Back to the Guides overview.