Skip to content

Web Development · Browser APIs

Web Push Notifications Without a Vendor: A Practical Implementation Guide

Yes, you can build browser push notifications yourself with the Push API, Notifications API, and a service worker. No OneSignal, no Firebase. Here's the full working setup, VAPID keys included.

Abhishek Gupta

Abhishek Gupta

9 min read

Web Push Notifications Without a Vendor: A Practical Implementation Guide

Sponsored

Share

A client asks for push notifications and someone on the team immediately reaches for OneSignal or Firebase. Fair enough, both work, but for a single site or app that just needs to tell users “your order shipped” or “you have a new message,” you’re pulling in a vendor SDK, a vendor dashboard, and a vendor’s data collection for something the browser already does natively. You can build this yourself with three standard web APIs and about 150 lines of code split between the client and the server. This post is that implementation, end to end.

The three pieces

Web push has three moving parts and it helps to keep them separate in your head, because each one fails differently.

The service worker is a script that runs in the background, independent of any open tab. It’s what lets a push arrive and produce a notification even if the user closed your site an hour ago. Without a registered service worker, none of this works, full stop.

The Push API is what lets your service worker subscribe the browser to a push service (a server run by the browser vendor: Google for Chrome, Mozilla for Firefox, Apple for Safari). Subscribing gives you a PushSubscription object containing an endpoint URL and encryption keys. That object is what your backend needs to send a message to that specific browser.

The Notifications API is what actually puts a notification on the user’s screen. It’s a separate API from Push; you can call Notification.requestPermission() and show notifications from a page that’s currently open too, but push is what lets you show one when nothing is open.

The service worker’s push event handler is the bridge: a push arrives from the push service, and the handler calls self.registration.showNotification(), which uses the Notifications API under the hood.

VAPID: identifying your server without a vendor account

Push services need to know your requests are legitimate and not someone spamming their infrastructure. That’s what VAPID (Voluntary Application Server Identification) solves. It’s a P-256 key pair: the public key gets embedded in your client code and sent along when you subscribe a user, and the private key stays on your server and signs every push request you send. The push service checks the signature against the public key it already has on file for that subscription. No account registration with Google or Mozilla required, no API keys to request, just a key pair you generate once.

Generate one with the web-push CLI:

npx web-push generate-vapid-keys

That prints a public and private key, both URL-safe base64 strings. Store the private key as a server-side environment variable, never in client code or a repo. The public key is meant to be public; it ships in your JavaScript bundle.

Registering the service worker and asking for permission

On the client, register the service worker on page load and request notification permission behind a user action, not on page load itself. Browsers increasingly ignore or auto-deny permission prompts that fire the instant a page opens, and users learn to reflexively dismiss them anyway.

// main.js
async function setupPushNotifications() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.log('Push notifications are not supported in this browser');
    return;
  }

  const registration = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;

  // Call this from a button click, e.g. "Notify me about new replies"
  document.querySelector('#enable-push').addEventListener('click', async () => {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') {
      console.log('User declined notification permission');
      return;
    }
    await subscribeUser(registration);
  });
}

async function subscribeUser(registration) {
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // required: every push must produce a visible notification
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
  });

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

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}

setupPushNotifications();

userVisibleOnly: true is not optional in practice; browsers require it and will reject a subscription request without it. It’s a promise to the browser that you won’t use push to run silent background code without showing the user something.

Storing subscriptions and sending a push from the server

Your backend needs an endpoint to save subscription objects and a way to send pushes later. Here’s a minimal Express example using the web-push package, which handles VAPID signing and RFC 8291 encryption for you.

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

const app = express();
app.use(express.json());

webpush.setVapidDetails(
  'mailto:support@youragency.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// In a real app this is a database table keyed by user id
const subscriptions = new Map();

app.post('/api/subscriptions', (req, res) => {
  const subscription = req.body;
  subscriptions.set(subscription.endpoint, subscription);
  res.status(201).json({ ok: true });
});

async function sendPushToAll(payload) {
  const results = await Promise.allSettled(
    [...subscriptions.values()].map((sub) =>
      webpush.sendNotification(sub, JSON.stringify(payload))
    )
  );

  results.forEach((result, i) => {
    if (result.status === 'rejected' && result.reason?.statusCode === 410) {
      // 410 Gone: the subscription expired or the user revoked permission.
      // Drop it so you stop retrying dead endpoints.
      const dead = [...subscriptions.values()][i];
      subscriptions.delete(dead.endpoint);
    }
  });
}

app.post('/api/notify', async (req, res) => {
  await sendPushToAll({
    title: req.body.title ?? 'New update',
    body: req.body.body ?? '',
    url: req.body.url ?? '/',
  });
  res.json({ ok: true });
});

app.listen(3000);

The 410 Gone handling matters more than it looks like it should. Subscriptions expire, browsers get uninstalled, users revoke permission from browser settings without ever visiting your site again. If you don’t prune dead subscriptions, you’ll keep sending pushes into the void and, at scale, push services will start rate-limiting a server with a high failure rate.

Handling the push in the service worker

This is the part that runs with no page open. The service worker listens for the push event, pulls the payload out, and shows a notification.

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

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', {
      body: data.body || '',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url || '/' },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clientList) => {
      const targetUrl = event.notification.data.url;
      const existing = clientList.find((c) => c.url === targetUrl);
      if (existing) return existing.focus();
      return self.clients.openWindow(targetUrl);
    })
  );
});

event.waitUntil() matters here: it tells the browser to keep the service worker alive until the promise resolves, otherwise it can get terminated mid-way through showing the notification. The notificationclick handler is what makes clicking the notification actually take the user somewhere, focusing an existing tab if one’s already open rather than piling up duplicates.

Encryption: what you don’t need to build

Every push payload is encrypted per RFC 8291 before it leaves your server. The push service that relays it (Google’s, Mozilla’s, Apple’s infrastructure) can’t read the contents, only your server and the subscribed browser hold the keys needed to decrypt it. This isn’t optional or something you can turn off; it’s baked into the protocol. The practical takeaway for a developer deciding whether to build this in-house: you’re not implementing a cipher, you’re calling webpush.sendNotification() and the library handles key derivation, encryption, and the Content-Encoding: aes128gcm header. If you’re evaluating a library in another language, confirm it implements RFC 8291 rather than an older draft; the older aesgcm encoding is deprecated and some push services have dropped support for it.

Browser support in 2026

Chrome, Firefox, and Edge support push notifications for a regular open tab on both desktop and Android, no installation step needed, and have for years. Safari on macOS supports it too, moving away from its older proprietary push system to the standard Push and Notification APIs.

iOS Safari is the one with a real constraint: push notifications work from iOS 16.4 onward, but only for a site that’s been added to the home screen as a PWA. A site open in a normal Safari tab cannot request or receive push notifications on iOS, even with a manifest and service worker in place; Apple has been explicit that this is a deliberate design choice, not a gap they’re planning to close. If a meaningful chunk of your audience is on iPhone, the practical move is to prompt for home screen installation first and gate the notification permission prompt behind that, rather than showing a permission dialog that quietly does nothing for iOS visitors. We cover the install flow and manifest requirements in more detail in our guide to progressive web apps in 2026.

It’s also worth being clear about what push is not for. If you need updates to show up while the user is actively looking at an open tab (a live order status, a chat message arriving in real time), push notifications are the wrong tool; that’s a job for Server-Sent Events or WebSockets while the page is open, with push reserved for when the tab is closed. We’ve written about choosing between SSE and WebSockets if that’s the piece you’re solving next.

Build it yourself or reach for a vendor

For a single product with a handful of notification types (new message, order update, comment reply), building this yourself is a day of work, not a project. You own the data, there’s no per-notification pricing, and no vendor JavaScript loading on every page. The code above is close to the whole thing; the rest is deciding what events trigger a push and writing the copy.

Reach for OneSignal, Firebase Cloud Messaging, or a similar vendor when the requirements grow past “send a notification”: segmenting users by behavior, A/B testing notification copy, scheduling sends across time zones, or centralized delivery and click-through analytics across many separate apps. Those are real product features that take real engineering time to build well, and a vendor that specializes in them can be worth the dependency and the cost. The mistake is reaching for that machinery before you know you need it, when a web-push call and a database table would have done the job.

Where this leaves you

Web push without a vendor is three browser APIs, one key pair, and a small amount of server code to store subscriptions and prune the dead ones. The web-push library (or its equivalent in your backend language) handles the cryptography, so the actual engineering work is deciding what events deserve a notification and building the small backend that sends them. Start there, and only add a vendor once you can point to a specific feature, like segmentation or analytics, that you’d otherwise have to build yourself.

Frequently asked questions

Can I add push notifications to a website without using a third-party service?
Yes. The Push API, Notifications API, and Service Worker API are standard browser features, no vendor account needed. You generate your own VAPID key pair, subscribe users from the client, store the subscription objects on your own server, and send pushes with a library like the Node.js `web-push` package (equivalents exist for Python, PHP, Go, and other languages). The only external dependency is the browser vendor's own push service, which is free and part of the browser.
Do I need a backend server to send web push notifications?
Yes. Push notifications are sent from your server to the browser's push service (Google's for Chrome, Mozilla's for Firefox, Apple's for Safari), not directly from one browser to another. Your server needs to store subscription objects per user and hold the VAPID private key so it can sign each outgoing push.
Does web push work on iPhone and iPad?
Since iOS 16.4, yes, but only when the site has been added to the home screen as a PWA. A regular Safari tab cannot receive push notifications on iOS even if the user grants permission; Apple has confirmed this is intentional. If a meaningful share of your users are on iPhone, plan for a home screen install prompt before you ask for notification permission.
Is the web push payload encrypted?
Yes, per RFC 8291. Push services can't read the content of what you're sending, only your server and the subscribed browser can. Libraries like `web-push` handle the encryption and the required `Content-Encoding: aes128gcm` header automatically, so you don't need to implement the cipher yourself.
When should I use OneSignal or Firebase Cloud Messaging instead of building push myself?
When you need things that are genuinely hard to build well: user segmentation, send-time optimization, A/B tested notification copy, delivery and click analytics, or a shared push pipeline across many separate apps or sites. For a single product that needs to notify users of new messages, order updates, or similar events, the vendor is often more setup and more vendor lock-in than the problem calls for.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored