Push Notifications
Server-to-device messages delivered through a service worker and the browser's push service — the only real-time channel that reaches users when your app is closed — built on the Push API, the Notifications API, VAPID authentication, and an explicit permission grant.
Why push is different. WebSockets and SSE live inside an open page — close the tab and the connection dies. Push Notifications are the one real-time technology that reaches a user even when your app is closed or backgrounded, because delivery is handled not by your page but by a service worker the browser wakes on demand. That makes push the tool for re-engagement: alerts, reminders, chat messages, breaking news, order updates.
The four players. (1) Your web app asks for permission and subscribes. (2) The service worker — a background script with no DOM — receives pushes and shows notifications. (3) The push service is run by the browser vendor (Google's FCM endpoints for Chrome, Mozilla's autopush for Firefox, Apple's for Safari); it holds the persistent connection to the device. (4) Your application (backend) server stores subscriptions and sends the actual messages. The browser abstracts the push service behind a single standard API, so you code against the spec, not the vendor.
Step 1 — register a service worker. Push requires a service worker and HTTPS (localhost is exempt for dev). You register it once: navigator.serviceWorker.register('/sw.js'). The returned registration is what you subscribe through. The service worker persists after the page closes, which is exactly why it can handle background pushes.
Step 2 — request permission (a user gesture, once). You call Notification.requestPermission(), which shows the browser's permission prompt and resolves to 'granted', 'denied', or 'default'. Ask in context — after the user does something that implies they want alerts — not on page load. A denial is often permanent for the origin, so a mistimed prompt burns your only chance. Always check Notification.permission first and degrade gracefully.
Step 3 — subscribe with a VAPID key. With permission granted you call registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }). VAPID (Voluntary Application Server Identification) is the mechanism that authenticates your server to the push service: you generate a public/private key pair once, the browser embeds your public key in the subscription, and your server signs each push with the private key. userVisibleOnly: true is a browser requirement — you must show a visible notification for each push (no silent tracking).
Step 4 — send the subscription to your backend. subscribe() returns a PushSubscription object containing an endpoint URL (unique to that device and push service) plus keys (p256dh and auth) used to encrypt the payload. You POST this JSON to your server and persist it, typically keyed by user. This subscription is the durable address your backend fires messages at — think of it as a per-device webhook URL.
Step 5 — the server pushes. Your backend uses the Web Push protocol (via a library like web-push in Node, or an equivalent) to send an encrypted payload to the subscription's endpoint, signed with your VAPID private key. Payloads must be encrypted end-to-end with the subscription's keys, so the push service relays without reading them. The push service then wakes the device's browser and hands off the message to your service worker.
Step 6 — the service worker shows the notification. In the service worker you listen for the 'push' event, parse the payload, and call self.registration.showNotification(title, options) — with body, icon, badge, actions, data, and tag (to collapse duplicates). Because userVisibleOnly is enforced, you must display something. You also handle 'notificationclick' to focus or open the relevant page, and 'notificationclose' for analytics.
Foreground vs background. Push always routes through the service worker. If you also use a higher-level SDK like Firebase Cloud Messaging (FCM), it distinguishes the two: when your page is in the foreground, FCM's onMessage fires so you can show a custom in-app toast; when the app is backgrounded or closed, the service worker's push handler shows an OS-level notification. FCM sits on top of the same standard Push API and adds token management, topics, and cross-platform delivery.
Push API vs Notifications API — don't conflate them. The Notifications API (new Notification(...), showNotification) just displays a notification and works without any server. The Push API is the transport that lets a server deliver a message to the service worker while the app is closed. Real push = Push API (delivery) + Notifications API (display). You can show a notification without push, but you cannot push without a service worker.
Lifecycle, expiry, and cleanup. Subscriptions can expire or be rotated by the push service; the 'pushsubscriptionchange' event lets you resubscribe and update your backend. When a send fails with a 404/410 from the endpoint, that subscription is dead — delete it server-side to stop wasting sends. Let users unsubscribe (subscription.unsubscribe()), and honor it by removing the record. Stale subscriptions are the main source of silent delivery failures.
Platform caveats to name. iOS/Safari only supports web push for apps added to the Home Screen (installed PWAs) and only from relatively recent versions. Permission UX, notification styling, and action support vary by browser and OS. Never rely on push for critical, must-arrive delivery — it is best-effort, and users can revoke permission at any time. Design a fallback (in-app inbox, email) for anything important.
The mental model (memorise this). Push is server-to-device delivery that works when the app is closed because a service worker, not your page, receives it: register a service worker, request permission in context, subscribe with your VAPID public key to get a PushSubscription (a per-device encrypted endpoint), store it on your backend, and have the server send VAPID-signed encrypted payloads over the Web Push protocol to the push service, which wakes the service worker to showNotification — foreground messages can be intercepted for in-app UI, everything is best-effort, and dead subscriptions must be pruned.
A PushSubscription is a durable, per-device **webhook URL** your backend stores: you persist the endpoint, then fire messages at it, and the push service (the broker — FCM/autopush/APNs) fans them out to the right device. VAPID is **mTLS/JWT for that webhook**: you hold a private key and sign every request so the broker knows the sender is really you, exactly like signing service-to-service calls. Payload encryption with the subscription's p256dh/auth keys is end-to-end encryption through an untrusted relay — the broker forwards bytes it cannot read. The service worker is a **background message consumer/daemon** that the OS wakes independently of any user session, the direct analogue of a Kafka consumer or a Vert.x verticle that runs whether or not anyone is watching. And pruning subscriptions on a 410 Gone response is the same dead-letter cleanup you do when a downstream endpoint stops accepting deliveries.
- Push is the only real-time channel that reaches users when the app is closed, because a service worker (not your page) handles delivery.
- Four players: your web app (subscribes), the service worker (receives/displays), the browser's push service (transport), and your backend (stores subscriptions and sends).
- It requires a service worker and HTTPS, plus an explicit permission grant via Notification.requestPermission() — ask in context, not on load, since denial is often permanent.
- You subscribe with pushManager.subscribe using your VAPID public key; userVisibleOnly: true forces you to show a visible notification per push.
- The PushSubscription has an endpoint URL plus p256dh/auth keys; you POST it to your backend, which uses it as a per-device address.
- The server sends VAPID-signed, end-to-end-encrypted payloads over the Web Push protocol; the push service relays without reading them.
- The service worker handles the 'push' event and calls showNotification, and handles 'notificationclick' to open/focus the app.
- The Push API is the transport (delivery to the service worker); the Notifications API just displays a notification — real push needs both.
- FCM sits on top of the standard Push API and splits foreground (onMessage, custom in-app UI) from background (service worker shows an OS notification).
- Subscriptions expire or rotate (pushsubscriptionchange); prune dead ones on 404/410, let users unsubscribe, and treat delivery as best-effort.
Worked Code
// Convert a base64url VAPID public key to the Uint8Array the API expects.
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(b64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY;
export async function enablePush(): Promise<void> {
// 1) Feature-detect and require a secure context.
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
// 2) Register the service worker (persists after the page closes).
const reg = await navigator.serviceWorker.register('/sw.js');
// 3) Request permission — call this from a user gesture, IN CONTEXT.
const permission = await Notification.requestPermission();
if (permission !== 'granted') return; // 'denied' is often permanent — degrade gracefully
// 4) Subscribe. userVisibleOnly is required: every push must show a notification.
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// 5) Send the subscription (endpoint + keys) to your backend to store.
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription), // { endpoint, keys: { p256dh, auth } }
});
}// sw.js — runs in the background with NO DOM; the browser wakes it for pushes.
// The 'push' event fires when a message arrives, even if no tab is open.
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
// userVisibleOnly is enforced: we MUST show a visible notification.
const promise = self.registration.showNotification(data.title || 'Update', {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
tag: data.tag, // same tag collapses duplicate notifications
data: { url: data.url || '/' },
actions: [{ action: 'open', title: 'View' }],
});
// waitUntil keeps the worker alive until the notification is shown.
event.waitUntil(promise);
});
// Focus an existing tab or open a new one when the user taps the notification.
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data.url;
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
const existing = clients.find((c) => c.url.includes(url));
if (existing) return existing.focus();
return self.clients.openWindow(url);
}),
);
});
// Subscriptions can rotate — resubscribe and tell the backend.
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(/* re-subscribe via pushManager and POST the new subscription */ Promise.resolve());
});// Node backend using the 'web-push' library.
const webpush = require('web-push');
// Generate the VAPID key pair ONCE (webpush.generateVAPIDKeys()) and store it.
webpush.setVapidDetails(
'mailto:alerts@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY, // signs each push so the push service trusts you
);
async function sendPush(subscription, message) {
try {
// Payload is end-to-end encrypted using the subscription's p256dh/auth keys;
// the push service relays it without being able to read it.
await webpush.sendNotification(subscription, JSON.stringify(message));
} catch (err) {
// 404/410 = the subscription is dead. Prune it so we stop wasting sends.
if (err.statusCode === 404 || err.statusCode === 410) {
await deleteSubscriptionFromDb(subscription.endpoint);
} else {
throw err;
}
}
}
// sendPush(stored, { title: 'Expense approved', body: '#4821 was approved', url: '/expenses/4821' });// FCM sits on top of the standard Push API and manages tokens for you.
import { getMessaging, getToken, onMessage } from 'firebase/messaging';
const messaging = getMessaging(app);
export async function registerFcm() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// getToken returns a device token (backed by a push subscription + VAPID key).
const token = await getToken(messaging, {
vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
});
// Store the token on your backend to target this device later.
await fetch('/api/fcm/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
}
// FOREGROUND: app tab is open -> show your OWN in-app UI (custom toast).
onMessage(messaging, (payload) => {
console.log('foreground message', payload.notification?.title);
// showInAppToast(payload.notification);
});
// BACKGROUND/closed: firebase-messaging-sw.js service worker shows the OS notification.Interview-Ready Q&A
- Register a service worker over HTTPS. 2) Request notification permission in context with Notification.requestPermission(). 3) On grant, call pushManager.subscribe with userVisibleOnly and your VAPID public key to get a PushSubscription (endpoint + p256dh/auth keys), and POST it to your backend. 4) Your server later uses the Web Push protocol to send a VAPID-signed, encrypted payload to that endpoint. 5) The browser's push service wakes the service worker, which fires the push event and calls showNotification. 6) notificationclick opens or focuses the relevant page.
- 1Push reaches users when the app is closed because a service worker, not your page, receives the message.
- 2Requires a service worker + HTTPS + an explicit permission grant; ask in context because denial is often permanent.
- 3Subscribe with pushManager.subscribe using userVisibleOnly: true and your VAPID public key.
- 4The PushSubscription (endpoint + p256dh/auth keys) is a per-device address you POST to and store on your backend.
- 5The server sends VAPID-signed, end-to-end-encrypted payloads over the Web Push protocol.
- 6The service worker handles the 'push' event -> showNotification, and 'notificationclick' -> focus/open a tab.
- 7Push API = transport (delivery to the SW); Notifications API = display. Real push needs both.
- 8FCM layers on top: foreground -> onMessage (custom in-app UI); background -> service worker shows an OS notification.
- 9Prune dead subscriptions on 404/410, handle pushsubscriptionchange, and let users unsubscribe.
- 10Delivery is best-effort; iOS needs an installed PWA — always have a fallback for critical messages.