The full lifecycle of a push notification in ~850 lines without a framework or a build step: permission → subscription → store → send → notification.
npm install
node generate-keys.js # once: writes the VAPID pair to .env
npm start # http://localhost:3456| File | What it holds |
|---|---|
generate-keys.js |
One-shot VAPID key generation. Refuses to overwrite an existing .env. |
server.js |
Four routes, the send logic, and the status-code handling. |
public/index.html |
Three buttons and an on-page log, plus the pre-prompt dialog. |
public/app.js |
Permission, subscribe, unsubscribe. Everything here is frontend → backend. |
public/sw.js |
The service worker: push, notificationclick, pushsubscriptionchange. |
subscriptions.json |
The "database". A flat file on purpose, so you can read it. |
| Route | What it does |
|---|---|
GET /api/vapid-public-key |
The public key. The client needs it for subscribe(). |
POST /api/subscriptions |
Stores {endpoint, keys}. Idempotent per endpoint. |
DELETE /api/subscriptions |
Removes by endpoint. |
POST /api/send |
Pushes to everyone. Accepts {"delay": 5}. |
Open the page in Chrome and enable notifications. The server console prints:
Push service for THIS browser: fcm.googleapis.com
That host is where your backend POSTs. It is Google, and this project has no Google
credentials at all (grep -ri firebase *.js public/*.js returns nothing).
cat subscriptions.jsonThe endpoint is right there in plain text. That is why this demo's "database" is a JSON
file and not SQLite.
Hit Send push (5s) and close the entire browser before the five seconds elapse.
The notification arrives anyway. A WebSocket dies with the tab; the service worker does not.
Repeat everything in Firefox without changing a line. The console now says:
Push service for THIS browser: updates.push.services.mozilla.com
Zero per-browser branching. That is what the standard gives you.
Delete one character from VAPID_PRIVATE_KEY in .env, restart, and send a push:
[403] YOUR OWN error against fcm.googleapis.com: permission denied: invalid JWT provided
NOT deleting the subscription. Check your VAPID keys or payload.
The subscription is still there. That is the point — plenty of real code deletes it here, which means one misconfigured key wipes the entire table on the first send.
| Code | Meaning | What this demo does |
|---|---|---|
| 404 / 410 | The subscription is genuinely dead | Deletes. The only correct case. |
| 400 | Malformed request | Logs. Keeps. |
| 401 / 403 | VAPID problem | Logs. Keeps. |
| 413 | Payload > 4096 bytes | Logs. Keeps. |
| 429 | Rate limited | Honours Retry-After. Keeps. |
node -e "
const c=require('crypto'), e=c.createECDH('prime256v1'); e.generateKeys();
console.log(JSON.stringify({endpoint:'https://fcm.googleapis.com/fcm/send/FAKE-TOKEN',
keys:{p256dh:e.getPublicKey().toString('base64url'),auth:c.randomBytes(16).toString('base64url')}}));
" | curl -s -X POST localhost:3456/api/subscriptions -H 'Content-Type: application/json' -d @-
curl -s -X POST localhost:3456/api/send -H 'Content-Type: application/json' -d '{"delay":0}'The log shows [410] dead subscription at fcm.googleapis.com -> deleted (correct). That 410
came back from Google, in a real HTTP response, because the token was made up.
Rotating VAPID keys invalidates EVERY existing subscription. Each one was minted by the
browser against your old public key and only accepts pushes signed with the matching private
key. That is why generate-keys.js refuses to overwrite an existing .env — and why a leaked
private key is expensive to fix.
The permission has one attempt. If the user clicks "Block", that "no" is sticky and cannot be requested again from code. Hence the pre-prompt.
- You need real HTTPS (localhost does not help from another device).
- On iOS the site must be installed to the Home Screen as a PWA. From a normal Safari tab there is no push, and that is not a bug in your code.