Web Push Notifications With Honest Consent
Design consent, browser permission, subscription repair, push-service acceptance, TTL, and inbox fallback as separate states.
Web Push notifications can bring someone back to important work, but a permission grant cannot tell you which topics they wanted or whether an alert reached their screen. This guide builds a consent-first lifecycle that separates topic choice, browser permission, subscription, push-service acceptance, expiry, and an in-app fallback.
Web Push notifications begin with a promise
Web Push notifications are useful when an event matters after the page closes. They also borrow attention from the operating system, so the first design decision is what the user is agreeing to receive. A browser permission prompt is a technical gate, not a complete explanation of topic, frequency, hours, or how to stop. Start with a product-level choice that names those terms before requesting browser permission.
Imagine a project review tool that can alert a person when an assigned decision needs input. The opt-in screen says “Critical review requests, at most once per decision, during your chosen hours.” It offers a preview, a channel preference, and a clear later path. The browser prompt happens only after an explicit click. If the person declines, the work remains available in the in-app inbox. This distinction prevents a blocked permission from becoming a broken product workflow.
The article's state machine follows intent, browser permission, subscription, service acceptance, and an observed display separately. These are not aliases. A push service can accept a message while the device is offline or a subscription has expired; acceptance alone cannot prove that a person saw a notification. Accessible toast notifications cover a different surface: feedback while the app is open.
The narrow question here is how to build a respectful opt-in and truthful recovery flow, not how to maximize permission-grant rate.
- Person selects a named topic and quiet-hours policy.
- Browser notification permission is requested from an explicit gesture.
- A PushSubscription is created for this browser and saved securely.
- Any decline, denial, or failure leaves the in-app work available.
Model permission, subscription, and delivery separately
For Web Push notifications, use separate fields for product consent, Notification permission, PushSubscription status, and each message's delivery evidence. Consent records the chosen topics and quiet-hours policy. Browser permission records whether notifications may be displayed. Subscription records the current endpoint and keys tied to the service worker. A delivery ledger records when a server attempted to send, what the push service returned, and whether an application-level acknowledgement exists. One field called “enabled” cannot represent this lifecycle.
The state diagram below allows a deliberate product opt-in, then a permission request, then a subscription attempt. Failure at any step returns to a useful in-app state. If permission becomes denied later, do not keep presenting a switch as “on.” If the endpoint changes, replace its server record rather than assuming the old one still works. If an unsubscribe call succeeds for one endpoint, state only that endpoint's result; it does not revoke every device or every copy of a notification.
A user-facing status vocabulary might be “off,” “ready on this browser,” “needs repair,” and “turned off here.” Delivery status should be even more careful: “queued,” “accepted by push service,” “expired,” and “opened in app” are distinct evidence. Service-worker update flow matters because code updates can change the handler even while an endpoint persists.
This split is the foundation of the push subscription lifecycle, and it makes error recovery possible without inventing certainty.
Ask at the moment of value
A Web Push notifications opt-in begins beside the action that explains the benefit: after a person assigns themselves to a review or follows a release. Describe the exact topic and default frequency. Include a visible “Not now” path. The permission request should be called from the user gesture after that explanation, and the interface should show what the browser actually returned instead of assuming the prompt appeared. A denied or dismissed prompt leaves the original task usable.
Keep consent granular enough for meaningful control. Someone may want review requests but not weekly activity summaries. Quiet hours belong in product preferences and must be enforced before attempting delivery; the browser's permission does not encode them. For critical workflows, offer an in-app inbox as the durable record, with Web Push notifications as a timely signal. That design still works on an unsupported browser or when a service worker cannot register.
A notification body should include the event and a safe destination, without leaking sensitive details onto a shared lock screen. The click destination should be an allowed in-origin route, not a server-provided arbitrary URL. Avoid deceptive urgency and avoid counting a permission grant as successful engagement.
Forms that respect time gives the same principle a different shape: ask for a commitment only when the value and consequence are clear.
Implement the subscription handshake
For Web Push notifications, feature-detect a secure context, service workers, PushManager, and Notification before offering a push-only action. Register the service worker, wait for readiness, request notification permission from the explicit gesture, and then create or retrieve a PushSubscription with the application's server key. Send the endpoint and keys to the server over an authenticated request. The server must bind that record to the account, topic preferences, and a local subscription identifier; the endpoint itself is a capability, not a user ID.
Do not keep the raw endpoint in analytics events or public logs. It is a sensitive routing address. A server should validate lengths and encodings, store only what it needs, and update the subscription when the browser returns a new endpoint. A page can compare its current subscription to the server record on settings open or app start, then offer “Repair notifications” when they diverge. A network failure during registration should not leave the switch falsely enabled.
The Push API governs the browser subscription and push event. Showing a visible notification is a separate step in the service worker, typically through registration.showNotification after validating the payload and product policy. Service worker notifications need their own display and click tests. The service worker must handle malformed or obsolete payloads safely and avoid putting private content into notification text. A notification click should focus or open the specific safe route.
The downloadable local simulator intentionally does not call a push service. It tests state transitions and wording before credentials, browser permission, and real devices enter the loop.
| Observed event | Safe claim |
|---|---|
| HTTP 201 | Push service accepted the message. |
| Worker acknowledgement | A user agent reached the service worker, if instrumented. |
| In-app open | The person opened the linked work in the app. |
Treat push-service acceptance as one hop
A server submits an encrypted message to a push service with an expiration policy. HTTP 201 means that service accepted the message; it is not a device-display receipt. RFC 8030 makes this separation explicit. A request can fail transiently, hit a stale subscription, or expire before a user agent retrieves it. A zero or short TTL may be appropriate for time-sensitive information that would become misleading later, while a longer TTL trades freshness for a bigger delivery window.
For each message, record an idempotency key, topic, enqueue time, TTL, quiet-hours decision, endpoint revision, push-service response, and eventual app acknowledgement if there is one. Retry only transient failures under a bounded policy. If an endpoint is invalid or gone, mark that subscription for repair and stop repeating the same message. A retry after TTL expiry should not resurrect a stale alert.
In the product interface, label this evidence precisely. “Sent” often implies arrival; “accepted by notification service” is more honest when no client acknowledgement exists. A user may have disabled notifications at the OS level, turned off the device, or cleared the item without opening the app. Resumable SSE client is useful for a foreground event stream, but it solves a different delivery path.
The in-app inbox remains the source of truth for the review request. Web Push notifications signal that an item exists; they do not become the item itself.
Design expiry, unsubscribe, and repair
Web Push notifications settings should show status for this browser and the account-level topic policy separately. “Turn off on this browser” can unsubscribe the current PushSubscription and remove its server record. “Stop review alerts” changes the account preference so future sends are suppressed across devices, while existing endpoints may still need cleanup. The copy must say which action occurred. If a browser permission was denied, the site cannot silently turn it back on; offer instructions appropriate to the user's browser rather than looping requestPermission.
Subscriptions may expire or rotate. On settings open, compare the live subscription with the server record. If missing or mismatched, show a repair action that performs the handshake again. When a send returns a permanent invalid-subscription response, retire that endpoint and surface a repair state in the app. Do not infer that an account has opted out because one device is gone.
The simulator's transition table includes an expiration event, a quiet-hours hold, a TTL expiry, a transient retry, and an unsubscribe. Each branch produces a status message and a next action. Those messages can be reviewed by a designer and an engineer together before integrating a push provider.
Also test a user who revokes permission outside the site. The app should reconcile that fact without blaming the user or hiding their unread inbox items.
- TTL expired: do not resurrect a stale alert.
- Quiet hours: hold or suppress according to the selected topic policy.
- Offline device: keep delivery status unknown until evidence arrives.
- Revoked permission: show a repair route without prompting in a loop.
- Unsubscribed endpoint: stop sending there; preserve account-level choices separately.
Audit the whole notification journey
Audit Web Push notifications with a fresh browser, a previously denied permission, an existing subscription, a replaced endpoint, an offline device, and a closed page. Confirm that the service worker displays only approved payloads, that clicks land on the intended route, and that a quiet-hours preference suppresses sends. Check that a TTL-expired message never appears later as if it were current. Repeat the test on supported desktop and mobile environments, because platform behavior differs.
Keep keyboard and screen-reader paths through the opt-in, settings, and repair states. The browser permission prompt is outside the page's accessibility tree, so the page must make the reason clear before it appears and provide status after it resolves. A notification should not be the only place where important information exists. If the display fails, the in-app task still needs its own visible state and timestamp.
For analytics, measure the funnel without collecting endpoint capabilities: eligible action, product opt-in, browser permission outcome, subscription outcome, push-service acceptance, and in-app open. Every metric has a different denominator. “Permission granted” is not “message delivered,” and an open rate is not a trustworthy delivery rate.
The standards and documentation behind this flow are the W3C Push API, MDN's notification guide, and RFC 8030. They define mechanics; the consent copy and fallback are product choices.
Ship a signal with an honest fallback
A Web Push notifications feature is ready when the product promise survives every transition, not merely when a test message appears. The opt-in states the topic and cadence. The subscription state is local to a browser. Server acceptance has a precise label. Expiry and revocation lead to repair or an in-app fallback. Account preferences can stop future sends without pretending to control an operating-system setting.
I would publish the state table beside the implementation, then run it against real browser and server logs. For a review request, the in-app inbox holds the durable event, while the notification is a disposable pointer. That makes quiet hours and TTL straightforward: suppress or expire the pointer without erasing the work. It also gives support staff a clear explanation when someone says an alert did not arrive.
The local simulator below accepts bounded event choices and displays the resulting status and evidence ledger. It is an interaction prototype for copy and failure paths, not a real delivery test. Move from it to a device matrix with authenticated server sends before promising the feature to users.
If you cannot explain which hop an observed status represents, the status is too broad. Replace it with the specific transition you can prove, and keep the user's work reachable.
Runnable local artifact — Local protocol simulation only; it sends no Web Push message and cannot prove device display.
Track product consent, browser permission, subscription, push-service acceptance, and observed open as distinct state fields.