Web Push API: Implementing Real Push Notifications
AI generated
JS
() =>
JavaScript · Browser APIs · Service Worker
Web Push API
Implementing real push notifications

The Notifications API only displays what already lives in the browser. To actually reach users after the tab has long been closed, you need the Web Push API: VAPID identity, a push subscription, and a server that delivers through the browser's push service.

17 min read Web Push API VAPID Push Subscription Service Worker

1. Notifications API vs. Web Push API: two different problems

Many people confuse the Notifications API with push notifications, yet the two solve different problems. The Notifications API shows a system notification window when JavaScript already running in the browser calls new Notification(...). It is pure display logic with no network component and only works while a page or service worker is active.

The Web Push API solves a different problem: it lets a server send a message to a browser even when no tab is open and the site has not been visited for days. That works through the browser vendor's push service, for example the Firebase Cloud Messaging endpoint for Chrome or the Mozilla Push Service for Firefox, which acts as an always-reachable intermediary between the server and the device. The server never talks to that service directly, only to the user's individual push subscription URL.

2. Generating a VAPID key pair: the server's identity

VAPID (Voluntary Application Server Identification) is a standard that lets an application server identify itself to the push service without prior registration with the push service operator. The key pair consists of a public and a private key based on elliptic curve cryptography (P-256). The public key goes to the client and is sent along when creating the subscription, while the private key stays exclusively on the server and later signs every outgoing push message.

The key pair is generated once, typically with a library such as web-push for Node.js, and stored permanently, because rotating the keys invalidates every existing subscription. The private key must be treated like a password and never shipped in the client bundle, while the public key can safely live in frontend code since it is only used for verification.


// Node.js: generate the VAPID key pair once (setup script)
import webpush from 'web-push';

const vapidKeys = webpush.generateVAPIDKeys();

console.log('Public Key: ', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);

// Store both values securely, e.g. as environment variables:
// VAPID_PUBLIC_KEY=...
// VAPID_PRIVATE_KEY=...
webpush.setVapidDetails(
  'mailto:contact@mironsoft.de',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY,
);

3. Creating a push subscription in the browser

On the client side, the Web Push API always requires a registered service worker, because only the service worker can receive push events while the page is closed. Through pushManager.subscribe(), the client requests a subscription from the browser's push service and passes applicationServerKey, the public VAPID key in Uint8Array form. The result is a PushSubscription object with a unique endpoint URL plus the encryption keys p256dh and auth.

These three values, endpoint, p256dh and auth, must be sent to your own server and associated with a user account. Without prior user permission via Notification.requestPermission(), subscribe() fails, so the permission request should always happen in the context of a clear user action, such as clicking 'Enable notifications', rather than automatically on page load.


// public/subscribe.js
const VAPID_PUBLIC_KEY = 'BEL9...publicKey...';

function 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].map((c) => c.charCodeAt(0)));
}

async function subscribeToPush() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}

4. Receiving push messages in the service worker

When a message arrives at the push service, the operating system wakes the browser and the service worker receives a push event, even without an open page. Inside the handler, the payload lives in event.data, usually encoded as JSON, and must be read with event.data.json(). The service worker builds the notification from that data with registration.showNotification().

event.waitUntil() is critical: the service worker may be terminated as soon as the event handler returns, so the asynchronous showNotification() promise must be passed explicitly to make the browser wait until the notification is actually visible. Skip this step and messages can silently get lost because the process ends prematurely.


// sw.js
self.addEventListener('push', (event) => {
  const payload = event.data ? event.data.json() : { title: 'New message' };

  const options = {
    body: payload.body,
    icon: '/icons/push-icon-192.png',
    badge: '/icons/push-badge-72.png',
    data: { url: payload.url || '/' },
  };

  event.waitUntil(
    self.registration.showNotification(payload.title, options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});

5. Sending server-side through the push service

Your own server never talks to the device directly. Instead it sends an encrypted HTTP request to the endpoint URL stored in the subscription. Libraries such as web-push handle the entire encryption according to the Web Push Encryption standard (aes128gcm) as well as signing with the private VAPID key, so developers only need to worry about the payload and the recipient.

If the push service responds with status code 404 or 410, the subscription has expired, for instance because the user revoked permission or cleared the browser cache, and it must be removed from your own database. A robust backend checks for this on every send and cleans up automatically instead of repeatedly sending to dead endpoints.


// server/sendPush.js
import webpush from 'web-push';

export async function sendPushToUser(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload),
    );
  } catch (error) {
    if (error.statusCode === 404 || error.statusCode === 410) {
      await removeExpiredSubscription(subscription.endpoint);
    } else {
      console.error('Push send failed:', error.statusCode, error.body);
    }
  }
}

6. Payload size, message structure and expiry

Push messages are deliberately small: the standard allows at most 4 KB of encrypted payload, far too little for images or full content. The common approach is to transmit only a title, short text and a URL, while icons and other assets are loaded by the client from its own cache or the network once the notification is rendered.

You can also set a TTL (Time To Live) per message, which determines how long the push service holds a message for a client that is temporarily offline. For time-critical content such as live offers, choose a short TTL so a stale message doesn't arrive days late, while general notices can use a longer TTL.


// Control TTL and urgency when sending
await webpush.sendNotification(subscription, JSON.stringify(payload), {
  TTL: 60 * 15,        // valid for 15 minutes
  urgency: 'high',     // low | normal | high, affects battery behavior
});

7. Permission UX: don't ask on page load

A common mistake is triggering the browser's notification permission dialog immediately on the first page visit. Users tend to reflexively dismiss such unexpected prompts, and once denied, the permission cannot be requested again through JavaScript without the user manually changing browser settings. The denial is effectively permanent.

A better approach is a two-step flow with your own custom pre-prompt: a self-built UI element first explains the benefit, for example 'Get notified when your order ships', and only after deliberate consent is the native requestPermission() dialog triggered. This meaningfully raises acceptance rates because users already know what they're agreeing to before the browser dialog appears.

8. Multiple devices per user and security considerations

A user frequently signs in from multiple devices, and each device creates its own independent PushSubscription with its own endpoint URL. Server-side, you should therefore not store a single subscription per user but a list, linked by user ID, so a message reaches all active devices at once.

When sending to multiple subscriptions of a user, it is best to fire requests in parallel rather than sequentially and to guard each one against failure individually, so an expired endpoint on an old phone doesn't block delivery to currently used devices. Promise.allSettled() suits this better than Promise.all(), since individual failures don't abort the remaining deliveries.

Push subscriptions contain personal, if pseudonymous, endpoint data and must be handled in line with data protection law: users need a clear way to unsubscribe from push notifications, and the server should delete subscriptions once a user account is deleted. End-to-end encryption of the payload protects the content from the push service operator, but not the fact that a message was sent at all.

On the server side, the VAPID private key and the subscription database must never be publicly reachable. A compromised private key lets attackers send arbitrary messages to all subscribers on behalf of the application, which is why the key should be managed like any other server secret, in a secret manager or environment variable, never in the repository.


// Send to all of a user's devices, fault-tolerant
export async function sendPushToAllDevices(userId, payload) {
  const subscriptions = await getSubscriptionsForUser(userId);

  const results = await Promise.allSettled(
    subscriptions.map((sub) => sendPushToUser(sub, payload))
  );

  return results.filter((r) => r.status === 'fulfilled').length;
}

9. Conclusion: use push deliberately, not just implement it

The Web Push API is not a replacement for email or in-app notifications, but an additional, very direct channel that should be used sparingly. Technically, the full flow consists of VAPID identity, client-side subscription, service worker reception and server-side encrypted sending, and each component can be tested independently.

Anyone running the API in production should clean up expired subscriptions from day one, choose TTL and payload size deliberately, and embed the permission request in a thoughtful user experience. The table below summarizes the key building blocks once more.

Building block Location Task Key detail
VAPID keys Server (once) Identity toward the push service Private key never in the client
pushManager.subscribe() Client Create the subscription Requires an active service worker
push event Service worker Receive and display the message event.waitUntil() is mandatory
webpush.sendNotification() Server Encrypted delivery 404/410 means remove the subscription

Mironsoft

Modern browser APIs, performance, and maintainable JavaScript

JavaScript that holds up in the real browser, not just in the tutorial?

We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.

Code Review

Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.

Performance Optimization

Improving bundle size, load time, and runtime performance with modern APIs.

Modernization

Deliberately introducing native browser APIs instead of heavy libraries.

10. Summary

Web Push API: The Key Facts at a Glance

VAPID

Server identity via a public/private key pair, generated once

Subscription

Created client-side via pushManager.subscribe() with applicationServerKey

Reception

Service worker catches push events, even when the tab is closed

Delivery

Server sends encrypted data to the push service's endpoint URL

11. FAQ: Web Push API: The Key Facts at a Glance

1What is the difference between the Notifications API and the Web Push API?
The Notifications API only shows a notification window when JavaScript is already running in the browser. The Web Push API additionally allows a server to deliver a message through the browser's push service, even when no tab is open.
2What exactly are VAPID keys for?
VAPID keys identify the application server to the push service. The public key goes to the client and is sent with the subscription, while the private key signs every outgoing message on the server.
3Can I use push notifications without a service worker?
No. A PushSubscription is created through the service worker's pushManager interface, and only the service worker can receive push events, even without an open tab.
4How large can the push payload be?
The standard limits the encrypted payload to 4 KB. For larger content, only a title, short text and a URL should be transmitted, with the rest loaded client-side afterwards.
5What does a 404 or 410 mean when sending to an endpoint?
These status codes indicate the subscription is no longer valid, for example because the user revoked permission. The subscription should then be removed from your own database.
6Why is the notification dialog often ignored or denied?
Because it often appears without context right on page load. A custom pre-prompt explaining the benefit before triggering the native dialog noticeably increases acceptance rates.
7How does push work across multiple devices of the same user?
Each device creates its own PushSubscription with its own endpoint URL. The server should store all of a user's subscriptions and send to all of them in parallel, fault-tolerantly.
8What does TTL do for a push message?
Time To Live determines how long the push service holds a message for a device that is temporarily offline. Time-critical content should use a short TTL.
9Is the Web Push API available in all browsers?
It is supported by all modern browsers including Safari from macOS Ventura and iOS 16.4 onward, though Safari additionally requires the site to be used as an installed web app.
10What privacy aspects do I need to consider?
Subscriptions count as personal data and must be unsubscribable. When a user account is deleted, its subscriptions should be removed too, and the private VAPID key must be protected like any other secret.