# Add push notifications

> Subscribe a service worker to a push service with a VAPID key, ask for notification permission from a click, and show the message when the push event arrives.

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](/guides/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](/compatibility/web-push/).

## 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.

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

## 2. Request permission from a click

`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.

```js
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();
});
```

## 3. Subscribe with your VAPID public key

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.

```js
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()),
  });
}
```

:::observed
Calling `subscribe()` in Chrome without `applicationServerKey` and without a
`gcm_sender_id` in the manifest rejects with a `DOMException` (name `AbortError`) whose
message is `Registration failed - missing applicationServerKey, and gcm_sender_id not found
in manifest`. Firefox accepts the same call, so a subscribe flow that was only tested in
Firefox fails on first contact with Chrome.
:::

## 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.

```js
// 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()),
        }),
      ),
  );
});
```

## 5. Show the notification in the worker

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.

```js
// 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);
    }),
  );
});
```

## 6. Send a test push from DevTools

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.

## See also

- [Web Push: subscriptions, permissions, and delivery](/reference/notifications/web-push/)
- [The Notifications API: system notifications](/reference/notifications/notifications-api/)
- [The notification permission model](/reference/notifications/permissions/)
- [iOS and Safari Web Push (16.4+)](/reference/notifications/ios-safari-push/)
- [PushManager: subscribe() method](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe) (developer.mozilla.org)
- [Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) (webkit.org)
- [Notification: requestPermission() static method](https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static) (developer.mozilla.org)

← Back to the [Guides](/guides/) overview.