The Web Push API: what it is and when to use it
You’ve seen the browser prompt: “Allow notifications?” Most users click Block. But for the developers who click Allow, something interesting happens — a website can now reach your device even when the tab is closed.
That’s the Web Push API. Here’s how it actually works.
What the Web Push API is
The Web Push API is a browser standard that lets a website deliver push notifications to a subscribed device. It’s not an npm package or a SaaS product — it’s a spec, implemented in Chrome, Firefox, and Safari (since 16.4). It works over the open web, coordinated through a push service run by the browser vendor (Google’s FCM for Chrome, Mozilla’s push service for Firefox, Apple’s APNs for Safari).
The pieces you assemble:
- A Service Worker — a background script the browser runs separately from your page, which handles incoming pushes.
PushManager.subscribe()— called in the browser to register the device and get aPushSubscription.- The
PushSubscription— an object containing the device’s push endpoint URL and encryption keys. You send this to your server. - Server-side delivery — your server POSTs an encrypted payload to the endpoint, authenticated via VAPID (Voluntary Application Server Identification).
Walking through the stack
The browser side subscribes the user and hands you a subscription object:
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
})
// sub.endpoint is the push service URL
// Send it to your server
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(sub),
})userVisibleOnly: true is not optional — browsers require it. It means every push must show a visible notification; you can’t use Web Push as a silent background sync channel.
applicationServerKey is your VAPID public key, a P-256 elliptic curve key pair. The private half lives on your server and signs the requests you send to the push service, proving you’re the same app that issued the subscription.
On your server, you construct and send the push. Libraries like web-push (Node/Bun) handle the ECDH encryption and VAPID signing:
import webpush from 'web-push'
webpush.setVapidDetails(
'mailto:[email protected]',
VAPID_PUBLIC_KEY,
VAPID_PRIVATE_KEY,
)
await webpush.sendNotification(
subscription,
JSON.stringify({ title: 'Payment received', body: '$42.00 from Ada' }),
)The push service (Google, Mozilla, Apple) receives it, validates the VAPID signature, decrypts the payload, and wakes the device.
Your service worker handles the arrival:
self.addEventListener('push', event => {
const data = event.data.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png',
})
)
})event.waitUntil() keeps the service worker alive until the notification is shown. Without it, the browser can kill the worker before showNotification resolves.
The iOS situation
Safari on iOS added Web Push support in 16.4, which shipped in March 2023. That’s the good news.
The requirements: the user must add the site to their Home Screen. A push subscription initiated in mobile Safari (not the Home Screen app) will fail silently. After installing, the user gets the standard permission prompt.
The hard limit: web push on iOS cannot break silent mode or Focus filters. If the user’s phone is on silent, your notification arrives — but makes no sound. fyi.critical() with Urgency: high and requireInteraction works on desktop Chrome and Android; on iOS it degrades to a normal push. This isn’t a bug or a gap in your implementation. It’s a platform constraint, documented honestly.
Permission UX matters more than people think. A prompt shown immediately on page load gets dismissed. A prompt shown after the user has done something meaningful — logged in, created a project, triggered an action — gets accepted. The Web Push API gives you the mechanism; you decide when to ask.
When to use the raw API
Build on the Web Push API directly when you’re building a product that notifies your users. You control the subscription lifecycle, the delivery, the payload shape. You store subscriptions, handle 404/410 responses (which mean the subscription expired and you should prune it), manage VAPID key rotation, and write the service worker logic. It’s the right layer for a product.
The reference implementations worth reading: the Chrome team’s web-push-codelab and Mozilla’s push docs. Both are thorough and current.
When to skip the raw API
If you’re a developer who wants push notifications for yourself — a cron job finished, a user signed up, a payment cleared — the raw API is the wrong tool. You’d be wiring up subscriptions, a database, a VAPID key pair, and a service worker just to tell yourself something happened.
Tools like Ntfy (self-hosted, topic-based) or Pushover (native apps, paid) solve this differently. trigger.fyi takes the minimal path: curl -d "deploy done" https://trigger.fyi/<key> or fyi("Ada Lovelace signed up") in code, and your phone buzzes. No service worker to write. The key is the app; there’s no channel param.
The layer that matters
The Web Push API is a transport. A subscription, a VAPID key, an encrypted POST to an endpoint — that’s the whole thing. What you build on it is up to you.
If you’re shipping a product that notifies users, learn the API properly. The subscription lifecycle, the VAPID auth, the service worker — you’ll need to own all of it.
If you want to be notified about your own systems, the raw API is underkill in the wrong direction. Reach for something that already has the plumbing.