How does a web page subscribe a user to push notifications with the Push API, and what does your application server need from the resulting `PushSubscription` in order to send a message?
answer
- three parties, not two
- a registration, a permission, a key pair
- endpoint plus two crypto values
- a promise to show something visible
- waitUntil or the worker dies
basics
~20 sCall registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }) on a service worker registration after notification permission is granted. The returned PushSubscription carries an endpoint URL plus p256dh and auth keys; the server needs all three to encrypt and deliver a message.
solid answer
~40 sPush needs a service worker registration, notification permission, and a VAPID key pair. The page calls `registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })`, where the key is your VAPID public key. The browser talks to its vendor's push service and returns a `PushSubscription` whose `toJSON()` gives an `endpoint` URL and two keys, `p256dh` and `auth`. You POST that whole object to your server. To send, the server encrypts the payload against `p256dh` and `auth`, signs a JWT with its VAPID private key, and POSTs to the `endpoint`. The push service wakes the browser, which fires a `push` event in the service worker; there you call `event.waitUntil(self.registration.showNotification(...))`. In Chrome `userVisibleOnly: true` is mandatory, and it is a promise to show a notification for every message.
code
javascript · 26 linesfunction urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from(raw, (char) => char.charCodeAt(0));
}
async function subscribeToPush(vapidPublicKey) {
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) return existing;
if ((await Notification.requestPermission()) !== 'granted') return null;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
await fetch('/api/push-subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return subscription;
}go deeper
Know that push requires a service worker, explicit user permission, and that the resulting subscription object must be sent to your own server before anything can be delivered.
Explain the round trip: subscribe with userVisibleOnly and the VAPID public key, store endpoint plus p256dh and auth, and handle the push event with waitUntil around showNotification.
Discuss what you would run in production — deduplicating with the notification tag, pushing ids instead of content because of the size cap, and the iOS constraint that push requires an installed Home Screen app.
Own the policy question: push permission is spent once, so decide which events deserve a notification at all, and how you measure whether the channel earns its permission cost rather than burning it.
## The three parties Web push involves your page and server, the *push service* run by the browser vendor (a different endpoint host for Chrome, Firefox and Safari), and the service worker. Your server never talks to the browser directly. It hands an encrypted blob to the push service, which holds a connection to the user's browser and delivers it — which is exactly why the message can arrive when no tab of your site is open. ## Subscribing ```js const registration = await navigator.serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); if (!subscription) { const permission = await Notification.requestPermission(); if (permission !== 'granted') return; subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), }); } await fetch('/api/push-subscriptions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription), }); ``` Two details matter here. `getSubscription()` first: subscribing when a subscription already exists is wasteful and can hand you a different object than the one your server has on file. And `applicationServerKey` takes a `Uint8Array` (or, in newer browsers, a base64url string) — the base64url-to-bytes conversion is the classic place this breaks, producing an `InvalidCharacterError` or a subscription your server cannot authenticate against. ## What userVisibleOnly means Passing `userVisibleOnly: true` is a promise that every push message will result in a notification the user can see. Chrome requires it and rejects a subscription without it. If a `push` handler finishes without calling `showNotification()`, the browser may display a generic "this site has been updated in the background" notification on your behalf, and repeatedly failing to show one can cost the site its push permission. Silent background pushes are not something the web platform grants ordinary sites. ## What the subscription contains `subscription.toJSON()` yields: ```json { "endpoint": "https://fcm.googleapis.com/fcm/send/xxxxx", "expirationTime": null, "keys": { "p256dh": "BE...", "auth": "k9..." } } ``` - `endpoint` — the push service URL that identifies this browser instance. Treat it as a secret; anyone holding it plus your VAPID key can push to that user. - `keys.p256dh` — the client's public key for the ECDH exchange used to derive the message encryption key. - `keys.auth` — an additional shared secret mixed into that derivation. - `expirationTime` — usually `null`; when non-null it is the point after which the subscription stops working. The payload is encrypted end-to-end between your server and the browser, so the push service transports bytes it cannot read. ## Sending from the server Sending is defined by the Web Push protocol: the payload is encrypted with `aes128gcm` using `p256dh` and `auth`, and the request to the endpoint carries an `Authorization` header holding a VAPID JWT signed with your private key — that is how the push service knows which application server is sending and can rate-limit or block it. Useful request headers include `TTL` (how long the push service may hold an undeliverable message) and `Urgency`. Payloads are small — around 4KB after encryption — so push an identifier, not a document, and let the service worker fetch details. In practice you use a library rather than hand-rolling the encryption; the point in an interview is knowing *why* three values are needed and what the VAPID pair proves. ## Receiving ```js self.addEventListener('push', (event) => { const data = event.data ? event.data.json() : {}; event.waitUntil( self.registration.showNotification(data.title ?? 'Update', { body: data.body, tag: data.id, // collapses repeats into one notification data: { url: data.url }, }) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil(clients.openWindow(event.notification.data.url)); }); ``` `event.waitUntil()` is not optional: the service worker was woken specifically to handle this message and can be terminated the moment the handler returns, so an un-awaited promise gets killed mid-flight and the notification never appears. ## Platform caveats Safari supports the standard Push API, but on iOS only for a web app the user has added to the Home Screen, from iOS 16.4 onward — a browser tab on iOS cannot subscribe. That single constraint shapes most real push rollouts, because it makes install a prerequisite rather than an enhancement.
- Why does the subscription include two key values rather than just one?`p256dh` is the browser's ECDH public key and `auth` is an extra shared secret. The sender derives the content encryption key from both, so possession of the endpoint alone is not enough to produce a readable payload. It also means the push service, which routes the message, cannot decrypt it.
- What is the VAPID key pair actually proving, given the payload is already encrypted?VAPID identifies the application server to the push service, not to the user. The signed JWT lets the push service attribute traffic to a known sender, contact you, and apply rate limits or blocks. Encryption protects confidentiality; VAPID provides sender accountability, so both are needed.
- What happens if your `push` handler decides there is nothing worth showing and returns without calling `showNotification()`?Under `userVisibleOnly: true` you have promised a visible result. The browser may show its own generic background-activity notification instead, and a site that repeatedly pushes without showing anything risks losing the permission. If a message may be silent, do not push it — sync it when the app next opens.
- Why push an identifier rather than the full content of the message?Payloads are capped at roughly 4KB after encryption, and content baked into the push is a snapshot that may already be stale by delivery. Sending an id lets the service worker fetch the current version, keeps sensitive content off the push path, and keeps the message inside the size limit.
saying these in an interview costs you the question
- Believing your server pushes directly to the browser
- Thinking the push service can read the payload
- Assuming a subscription works without a service worker registration
- Expecting silent background pushes for ordinary sites
- Omitting event.waitUntil() around showNotification