You get exactly one attempt. If the user clicks "Block", that "no" is sticky and you cannot ask again from code, ever. They have to dig into site settings by hand.
The fix is a pre-prompt: your own dialog, in your own UI, explaining what the notifications
are for. Only on a "yes" do you call requestPermission(). A "not now" costs you nothing; a
"Block" costs you that user forever.
Implemented in demo/public/index.html and demo/public/app.js.
The browser can rotate an endpoint on its own, with no user action. Miss this service worker event and the user stops receiving notifications while nobody finds out — your server still believes the old endpoint works. It will keep believing that until a send fails.
Most implementations in the wild simply do not have this handler. Implemented in
demo/public/sw.js.
The single most damaging bug in this area. See the table in
01-concepts.md.
Short version: delete on 404/410 only. On 400/401/403 the error is yours, not the user's, and a misconfigured VAPID pair returns 401/403 on every single send — so "clean up on error" wipes your whole table on the first attempt.
awaiting the push send inside your HTTP handler, sequentially per device, makes the response
wait on a round-trip to Google for every subscription a user has. In production this belongs
in a queue (BullMQ, pg-boss, SQS). There are no retries in the naive version either: a 429 or
a 5xx is lost forever.
It is mandatory on Chrome and it is not decorative: you are committing to showing a visible notification for every push you receive. Use push for silent data sync and the browser will eventually revoke the permission.
If your server generates keys at runtime when it cannot find them, a lost file during a deploy mints a fresh pair — and every existing subscription dies with no error anywhere. Keys belong in environment variables, and a missing key should be a loud startup failure.
demo/generate-keys.js also refuses to overwrite an existing .env for the same reason.
It is a secret, exactly like a JWT signing key. And it is worse than the usual leaked credential, because the correct remediation — rotating — invalidates every subscription you have. Every user has to opt in again.
Put it in .env, gitignore .env, and verify with git check-ignore -v .env.
One person has a phone, a tablet and a laptop. Key your storage on
(user_id, endpoint), not user_id.
Call subscription.unsubscribe() in the browser first, then tell your server.
Do it the other way round and a failed unsubscribe() leaves the browser subscribed while the
server has forgotten: the user gets nothing, and nothing self-heals. In the right order, a
failed server call leaves an orphan row that the next send answers with 410 — which cleans
itself up.
It is encrypted in transit, but it is decrypted on the device and sits there on the lock screen. Send an identifier and let the client fetch the content.
4096 bytes of encrypted payload. Keep your JSON under ~3KB to be safe. Over the limit you get a 413.
On iOS the site must be installed to the Home Screen as a PWA. If you test in a normal Safari tab and "it doesn't work", it is not your code.
Required everywhere except localhost. That exemption is why the demo in this repo runs
without a certificate — but the moment you test from a phone on your LAN, you need real HTTPS.
- VAPID keys in env vars, loud failure when missing, never committed
- Pre-prompt before
requestPermission() -
pushsubscriptionchangehandler in the service worker - Delete on 404/410 only
- Honour
Retry-Afteron 429 - Sending happens in a queue, not in the request
- Subscriptions keyed per device
-
TTLset to something sane for your use case -
topicset where collapsing makes sense - No sensitive content in the payload
- Tested on Chrome and Firefox
- Tested on iOS as an installed PWA