Your app cannot receive anything while it is closed. There is no process of yours running. So you always need a third party that is permanently awake: a service operated by the browser vendor or the OS, which keeps a persistent connection to the device and knows how to wake it up.
That is the first thing to internalise: with push you never talk directly to the user's device. You ask an intermediary to wake it. Everything else — VAPID, FCM, APNs — is a variation on who that intermediary is and how you prove you are allowed to use it.
An IETF standard split across three RFCs worth telling apart:
| RFC | Defines |
|---|---|
| 8030 | The delivery protocol itself (how you POST to an endpoint) |
| 8291 | Payload encryption (so the intermediary cannot read your content) |
| 8292 | VAPID — how you identify yourself as an application server |
There are four actors:
┌─────────────────┐ ┌──────────────────┐
│ YOUR BACKEND │ 1. encrypted POST │ PUSH SERVICE │
│ (Application │─────────────────────>│ (the browser's: │
│ Server) │ + VAPID-signed JWT │ Google/Mozilla/│
└─────────────────┘ │ Apple/MS) │
▲ └──────────────────┘
│ │
│ 3. you store the │ 4. wakes the
│ PushSubscription │ device
│ ▼
┌─────────────────┐ ┌──────────────────┐
│ YOUR FRONTEND │ 2. subscribe() │ SERVICE WORKER │
│ │─────────────────────>│ 'push' event │
└─────────────────┘ └──────────────────┘
- Ask for permission:
Notification.requestPermission(). - Subscribe:
registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }). The browser talks to its push service and hands you back a PushSubscription. - That subscription has three fields, and they are everything you need:
{
endpoint: "https://fcm.googleapis.com/fcm/send/eH7x...", // the address
keys: {
p256dh: "BN4G...", // the client's ECDH P-256 public key
auth: "tBHI..." // a 16-byte auth secret
}
}- Send it to your backend and store it.
- To send: your server encrypts the payload with
p256dh+auth, signs a JWT with your VAPID private key, and POSTs to the endpoint URL.
(a) The endpoint is the address. There is no "device token" you request from Google. The URL already encodes everything: which push service to reach and which device. That is why your code never needs to know which browser it is talking to — it just POSTs where it was told.
(b) The payload is end-to-end encrypted. Google, Mozilla and Apple cannot read the
contents of your notifications. That is what p256dh and auth are for: they are the
client's key material. The push service sees an opaque blob and an address. This is a real,
strong difference from native push.
Voluntary Application server IDentification. An ECDSA P-256 key pair you generate once for your whole application — not per user, not per device.
- The public key goes to the browser as
applicationServerKeyat subscribe time. - The private key signs a JWT on every send.
Look at the sizes to make it concrete: the public key is 87 characters (65 bytes — an uncompressed P-256 point — in base64url) and the private one is 43 (a 32-byte scalar). No magic, just an elliptic curve.
The JWT you sign on each send looks like this:
header: { "typ": "JWT", "alg": "ES256" }
claims: { "aud": "https://fcm.googleapis.com", // origin of the push service
"exp": <at most 24h in the future>,
"sub": "mailto:admin@yourapp.com" } // how to reach you
and travels as Authorization: vapid t=<jwt>, k=<base64url public key>.
- It identifies you to the push service. The
subclaim is an email or URL of yours: if you are abusing the service, they know who to write to before blocking you. - It binds the subscription to you — this is the important one. A subscription created with your public key only accepts pushes signed with your private key. If someone steals an endpoint out of your database, they cannot use it to spam your users.
If you rotate the VAPID pair, every existing subscription stops working. They were minted against the old public key, and the push service answers 403. All of your users have to subscribe again.
This is why a leaked VAPID private key is expensive: rotating is the correct fix, and the cost is that everybody loses notifications until they re-subscribe.
Useful history: before VAPID, Chrome required a Google account and a GCM/FCM sender ID
for web push. VAPID is exactly what removed that requirement. If you find tutorials talking
about gcm_sender_id in the manifest, they are stale.
You do not choose the push service. The browser does.
| Browser | Push service | Endpoint you get |
|---|---|---|
| Chrome / Edge | FCM (Google) | fcm.googleapis.com/... |
| Firefox | Mozilla autopush | updates.push.services.mozilla.com/... |
| Safari | APNs (Apple) | web.push.apple.com/... |
With standard Web Push this is transparent: you use VAPID and the same library against all three. You configure nothing with Google or Apple.
This is what confuses everyone:
- FCM as Chrome's push service → invisible plumbing. If your endpoint says
fcm.googleapis.com, you are already "using FCM" without a Firebase account and without knowing it. - FCM as a product/SDK → Firebase Cloud Messaging, Google's platform with its own SDK for Android, iOS and web. There you get a registration token per device and send via the HTTP v1 API, authenticated with a Google service account.
The second is a layer on top of the first. And on iOS, FCM-the-product is literally a wrapper over APNs: you upload your APNs key to Firebase and Firebase relays. It does not save you the Apple account, it saves you the code.
A practical note: the legacy FCM API (the "server key" one) was shut down in June 2024. It is HTTP v1 with OAuth2 now. A great many tutorials are out of date on this.
Apple's system, and the only route to native iOS/iPadOS/macOS. Authentication by token (.p8) — conceptually almost identical to VAPID: an ES256 JWT signed with your key — or by certificate (.p12), which is the old way. Use .p8: one key covers all your apps and it does not expire.
What matters most if you are doing web: Safari has supported standard Web Push since macOS Ventura (Safari 16.1) and iOS 16.4. But iOS adds a requirement that breaks a lot of plans:
On iOS the site must be installed to the Home Screen as a PWA. From a regular Safari tab there is no push. Full stop.
OneSignal is a SaaS platform wrapping Web Push + FCM + APNs behind one SDK and a dashboard. It is not a protocol or a technical competitor: it is a product layer.
What it gives you that you would otherwise build:
- Audience segmentation, scheduled sends, A/B testing
- Delivery and open-rate analytics (this alone is substantial work)
- Retries, unsubscribe handling, deduplication
- A dashboard so marketing can run campaigns without touching your code
- In-app messages, email and SMS through the same channel
The cost: you hand your subscriber list and message content to a third party, and you are tied to the vendor. The free tier is generous.
Others in the same space: Pusher Beams, Airship, Braze, Iterable, Knock, and Novu (open source, self-hostable).
Recommendation for learning: start with raw Web Push. Once you have done it by hand you understand exactly what OneSignal is selling you and can decide whether you need it. It does not work the other way round: start with OneSignal and you never learn which part is the standard and which part is their product.
The server-side implementation of the protocol. Maintained by the web-push-libs org, it is
the de-facto reference in the JS ecosystem (equivalents exist as pywebpush for Python,
web-push-php, and others for Go).
It does three things for you, and they are exactly the three you do not want to hand-roll:
- Encrypts the payload per RFC 8291 (
aes128gcm) usingp256dhandauth. - Builds and signs the VAPID JWT (RFC 8292).
- POSTs to the endpoint, whether it belongs to Google, Mozilla or Apple.
That third point is the good part: one library, zero per-browser branching.
const webpush = require('web-push');
// Once in your life, and you keep the result:
const keys = webpush.generateVAPIDKeys(); // { publicKey, privateKey }
webpush.setVapidDetails(
'mailto:you@yourapp.com', // the "sub" claim
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY,
);
await webpush.sendNotification(
subscription, // the stored {endpoint, keys} object
JSON.stringify({ title, body }), // payload, ~4KB max
{ TTL: 3600, urgency: 'high', topic: 'chat-42' },
);| Option | What it does |
|---|---|
TTL |
Seconds the push service holds the message while the device is offline. Library default: four weeks. For a chat you want something short — a "new message" alert from three weeks ago is garbage. |
urgency |
very-low / low / normal / high. The device may defer low-urgency messages to save battery. |
topic |
Collapse key: a new push with the same topic replaces an undelivered earlier one. Ideal for "you have 3 new messages" instead of three stacked notifications. |
sendNotification throws a WebPushError carrying statusCode. Handling these correctly is
the difference between a system that works and one that destroys itself.
| Code | Meaning | What to do |
|---|---|---|
| 404 / 410 | The subscription is dead (uninstalled, data cleared, revoked) | Delete the row. The only case where deleting is right. |
| 400 | Malformed request | Your bug. Log it. Do not delete. |
| 401 / 403 | VAPID problem: wrong keys, expired JWT, mismatch | Your bug. Never delete. |
| 413 | Payload > 4096 bytes | Shrink it. Send an id, not the whole content. |
| 429 | Rate limited | Honour Retry-After and back off. |
Deleting on 400/401/403 is a real and surprisingly common bug. A misconfigured VAPID pair returns 401/403 on every send, so the first attempt wipes your entire table of valid subscriptions — precisely when you already had a problem.