VAPID push notifications: a practical guide
You search “web push notification Node.js” and find three tutorials. Each one starts with “first, generate your VAPID keys.” None of them explain what VAPID actually is or why it exists.
Here’s the explanation.
What VAPID actually solves
Before VAPID, any server could send a push notification to any endpoint. The browser vendor (Google, Mozilla) had no way to know who was sending. If a push service got abused, they couldn’t trace it back to an application server — just a stream of anonymous requests.
VAPID — Voluntary Application Server Identification for Web Push, defined in RFC 8292 — fixes this by requiring you to sign every push request with a private key. The push service verifies the signature against the public key you embedded in the browser subscription. Now they know who’s sending, can rate-limit or block by identity, and you have a cryptographic claim over your endpoints.
That’s it. VAPID is identity, not encryption. The payload encryption is a separate spec (RFC 8291).
The push flow, concretely
There are three steps. Each one happens in a different place.
1. Subscribe in the browser. The browser calls pushManager.subscribe() with your VAPID public key. If the user grants permission, the browser registers with the push service (FCM for Chrome, Mozilla’s service for Firefox, APNs-via-WebKit for Safari) and returns a PushSubscription object containing an endpoint URL and a pair of encryption keys.
2. Send that subscription to your server. You POST the subscription JSON to your backend and store it. This is just a database write.
3. Send a push from your server. Your server makes an authenticated POST to the endpoint URL, with a VAPID-signed JWT in the Authorization header and an encrypted payload body. The push service delivers it. The browser wakes your service worker, which shows the notification.
The VAPID signature proves to the push service that the request came from the same party that created the subscription. Without it, most push services reject the request outright.
A working Node.js implementation
const webpush = require('web-push')
// Generate these once: npx web-push generate-vapid-keys
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
)
// subscription is the PushSubscription object from the browser
await webpush.sendNotification(subscription, JSON.stringify({
title: 'Deploy done',
body: 'Production · commit 7f3a2c1'
}))The web-push library handles the JWT signing, payload encryption (AES-128-GCM, content-encoding: aes128gcm), and the HTTP request to the push service endpoint. Without it you’re doing ECDH key agreement, AES-GCM encryption, and ECDSA P-256 JWT signing by hand. Doable. Not fun.
What you still have to build
The web-push snippet above is the easy part. The surrounding infrastructure is where the real work is:
- Subscription storage. Every device that subscribes gives you a unique endpoint. You need a table, indexed by user, with each subscription as a row.
- Stale endpoint pruning. When a subscription expires or a user uninstalls, the push service returns a 404 or 410. You have to catch those and delete the row. Otherwise your database fills up with dead endpoints and your delivery rate silently drops.
- Fanout. One user might have three devices. Sending to all of them means iterating subscriptions and handling partial failures without aborting the batch.
- The service worker. A
pushevent handler in a service worker file, deployed statically, handling notification click routing. - VAPID key rotation. If you rotate keys, old subscriptions break. You need a migration path.
This is 400–800 lines of code that has nothing to do with your actual product.
When to build it vs when to skip it
Build it yourself if:
- You’re building a product about notifications (this is your core feature)
- You need fine-grained control over delivery timing, batching, or silent pushes
- You have compliance requirements that prevent sending data through a third-party service
Use a service if:
- You want to be notified when something happens in your own code — a signup, a deploy, an error
For the second case, the tooling comparison is:
OneSignal gives you a dashboard, segments, A/B tests, and a 200MB SDK. It’s built for marketing notifications and requires account registration before you send anything.
Knock is an orchestration layer — routing, preferences, channels (email, Slack, SMS, push). Correct for product notification systems. Over-engineered for debugging a background job.
Ntfy is self-hostable, anonymous-friendly, and pub/sub based. No VAPID complexity — it runs its own push infrastructure. The tradeoff: it needs an ntfy client app, not your own PWA.
trigger.fyi is built on VAPID under the hood — the VAPID signing, subscription storage, stale endpoint pruning, all of it is handled. From your code, it’s a POST request:
curl -d "Deploy done" https://trigger.fyi/$TRIGGER_FYI_SECRET_KEYOr from Node:
import fyi from "trigger.fyi"
fyi("Deploy done", { body: "Production · commit 7f3a2c1" })That’s it. No service worker to write, no subscription table, no JWT. The notification lands on your phone.
The spec is simple. The plumbing isn’t.
VAPID itself is elegant: sign a JWT with your private key, include it in the Authorization header, done. The browser vendor can trace every push back to an origin.
The complexity isn’t in the spec — it’s in everything the spec doesn’t cover. Subscription lifecycle. Key management. Fanout. Service worker routing. These are solved problems, but they take time to build correctly and time to maintain when the push services update their requirements.
Whether you implement VAPID yourself or hand it off, knowing what it actually does makes the tradeoff legible. You’re not choosing between magic and plumbing. You’re choosing how much plumbing you want to own.