One-Click Push Unsubscribe From a Notification
A user who wants out is already holding the perfect opt-out surface: the notification that just interrupted them. An Unsubscribe action button collapses a three-step journey — open a tab, find settings, find the toggle — into one tap that never leaves the notification shade.
Quick answer
Add a second entry to the actions array when you call showNotification(), branch on event.action inside notificationclick, and run the entire opt-out inside event.waitUntil(). Report the withdrawal to your server before calling subscription.unsubscribe(), because unsubscribing destroys the endpoint that identifies the subscriber. Confirm with a replacement notification instead of opening a tab.
| Step | API surface | Common failure |
|---|---|---|
| Render the button | showNotification(title, { actions }) |
more entries than Notification.maxActions |
| Detect the tap | event.action === 'unsubscribe' |
reading notification.data and ignoring action |
| Keep the worker alive | event.waitUntil(promise) |
handler returns before the fetch settles |
| Log the withdrawal | POST /push/opt-out |
it runs after unsubscribe() |
| Tear the endpoint down | subscription.unsubscribe() |
no pushManager.getSubscription() lookup |
| Confirm | registration.showNotification(...) |
clients.openWindow() instead |
The actions array, and the two-button ceiling
actions is an array of { action, title, icon } objects passed to showNotification(). The action field is an opaque identifier you choose; the title is what the user reads. Nothing in the spec guarantees how many of them survive: the browser exposes its own limit as the static Notification.maxActions, and every entry past that index is discarded silently — no exception, no console warning, no fallback rendering.
In practice Chrome and Edge expose two actions on desktop and Android. Firefox also renders two. Safari on macOS and iOS ignores the array entirely and renders a body-click-only card. Two is therefore the design budget, and an Unsubscribe action costs you half of it — which is precisely why it belongs on lifecycle and marketing sends, not on transactional alerts where the primary action is the whole point. The full support matrix for notification actions tracks the per-engine detail, and the guide to adding action buttons covers icon sizing and title truncation.
Slice defensively rather than trusting the browser to cope:
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:alerts@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
const payload = JSON.stringify({
title: 'Price drop on a saved item',
body: 'Now 42.00, down 18% since you saved it',
tag: 'price-drop-8871',
data: { url: '/items/8871', campaignId: 'price-drop-2026-07' },
actions: [
{ action: 'view', title: 'View item' },
{ action: 'unsubscribe', title: 'Unsubscribe' }
]
});
await webpush.sendNotification(subscription, payload, { TTL: 86400 });
self.addEventListener('push', (event) => {
const p = event.data.json();
event.waitUntil(
self.registration.showNotification(p.title, {
body: p.body,
tag: p.tag,
icon: '/icons/notify-192.png',
data: p.data,
actions: (p.actions || []).slice(0, Notification.maxActions)
})
);
});
The payload is encrypted with the aes128gcm content encoding defined in RFC 8291 and must stay inside the 4 KB ceiling that every push service enforces. Two action entries cost well under a hundred bytes; the data blob is where payload budgets actually die, so keep campaign metadata to identifiers rather than embedding copy you can look up server-side.
Handling the tap in notificationclick
notificationclick fires for every interaction with the card, including a plain body tap. The discriminator is event.action: it carries the action string of the button that was pressed, or an empty string when the user tapped the body. Read it first, branch, and never assume the campaign metadata in notification.data tells you what the user wanted.
Everything asynchronous must be wrapped in event.waitUntil(). Without it the service worker is free to terminate the moment your handler returns, and a fetch() that has not settled dies with it — which in this flow means a silently lost opt-out.
The handler itself stays small. Delegate the work to named async functions so the branch reads as an intent, not as plumbing:
self.addEventListener('notificationclick', (event) => {
const notification = event.notification;
notification.close();
if (event.action !== 'unsubscribe') {
event.waitUntil(openTarget(notification.data && notification.data.url));
return;
}
event.waitUntil(unsubscribeFromNotification(notification.data || {}));
});
async function unsubscribeFromNotification(data) {
const subscription = await self.registration.pushManager.getSubscription();
if (subscription) {
// 1. Report while the endpoint still identifies the subscriber.
const reported = await reportOptOut(subscription.endpoint, data);
if (!reported) await queueForRetry(subscription.endpoint, data);
// 2. Only then destroy it.
await subscription.unsubscribe();
}
// 3. Confirm in place. No navigation, no tab.
await self.registration.showNotification('You are unsubscribed', {
body: 'No further notifications from shop.example.com. You can turn them back on in account settings.',
tag: 'unsubscribe-confirmation',
icon: '/icons/notify-192.png',
data: { url: '/account/notifications' }
});
}
async function reportOptOut(endpoint, data) {
try {
const res = await fetch('/push/opt-out', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
endpoint,
reason: 'notification_action',
campaignId: data.campaignId || null,
occurredAt: new Date().toISOString()
})
});
return res.ok;
} catch {
return false;
}
}
queueForRetry() writes the endpoint and reason into IndexedDB and registers a Background Sync tag, so an offline tap still reaches the server on the next connection. It matters because the tap is the user’s final interaction — there is no page to retry from.
Report before you tear down
The endpoint is the only join key between the browser and your subscriber row. subscription.unsubscribe() invalidates it immediately and irrecoverably: the browser will not hand it back, and the push service will answer 410 Gone for anything you send afterwards. If your POST /push/opt-out runs after the teardown and fails, you have a subscriber who believes they opted out and a database that still thinks they are active.
Relying on the eventual 410 Gone to clean up is not equivalent. It arrives whenever the next send happens, it carries no reason code, and it cannot distinguish a deliberate opt-out from a browser reinstall or an expired endpoint. Treat 410 handling at scale as hygiene for stale rows, and the opt-out report as the consent record.
The server side is two statements — one append-only event, one status update:
INSERT INTO push_consent_events (
subscription_id, event_type, mechanism, campaign_id, occurred_at, recorded_at
)
SELECT s.id, 'withdrawn', 'notification_action', $2, $3, now()
FROM push_subscriptions s
WHERE s.endpoint = $1;
UPDATE push_subscriptions
SET status = 'unsubscribed', unsubscribed_at = now()
WHERE endpoint = $1;
Recording mechanism = 'notification_action' separately from a preference-centre opt-out matters later: withdrawal from the notification itself is a strong fatigue signal, and it segments differently from a deliberate visit to settings. The schema and retention rules live in the GDPR-compliant unsubscribe logging reference.
Confirming without opening a tab
The temptation is to call clients.openWindow('/account/notifications') so the user “sees something happened”. Do not. Opening a tab as the response to leaving reads as a retention trap, and on Android it yanks the user out of whatever app they were in. It is also the single most common source of the wrong-window bugs described in notificationclick not opening the right window.
A replacement notification is the correct acknowledgement. Give it a distinct tag so it does not silently collapse into the campaign notification it replaces, keep requireInteraction at its default so it auto-dismisses, and put the re-subscribe route in data.url so a body tap on the confirmation — and only a deliberate tap — takes the user to settings. One card in, one card out.
Accessibility and consent-logging requirements
Action buttons are rendered by the operating system’s notification surface, not by your CSS, so most of the usual accessibility work is out of your hands — and the parts that remain are entirely about the words.
- Label the action, not the feeling. Screen readers announce the
titleverbatim with no surrounding context. “Unsubscribe” is unambiguous; “Stop”, “No thanks” and “Turn off” are not, and a bare icon-only action is unusable. Keep titles under roughly 12 characters or Android truncates them mid-word. - Assume there is no confirmation step. A notification cannot show a modal, so the tap is final the instant it lands. That makes reversibility the accessibility feature: state the outcome plainly in the confirmation body and name the exact place to undo it. The accessible opt-out flow patterns cover the settings-side counterpart, including focus management and live-region announcements.
- Log the same fields you would log from a form. Timestamp, mechanism, campaign identifier, and the subscription identity. An opt-out taken from a notification is a withdrawal of consent under GDPR Article 7(3) exactly as much as a toggle in a preference centre, and “the user tapped a button in a notification” is not a defensible audit entry on its own.
- Do not require an authenticated session. The service worker’s
fetchcarries cookies withcredentials: 'include', but the user may be logged out. Accept the endpoint as sufficient identity for a withdrawal — you are removing consent, not granting access. - Never treat the action as a preference update. Unsubscribing from the shade removes the browser subscription entirely; it does not express a topic preference. If you want granularity, that belongs in a preference centre, not in a second action button you do not have room for.
Gotchas and edge cases
Notification.maxActionsis not available in every worker scope at parse time. Read it inside thepushhandler rather than at module top level, and fall back to2if it isundefined. Slicing againstundefinedproduces an empty array and your buttons vanish.- A denied permission does not unsubscribe anything. If the user blocks notifications in browser settings, the
PushSubscriptionsurvives and your server keeps sending into an endpoint that will never display anything. That is a different failure with a different fix — see the re-permission recovery flows area. unsubscribe()resolvesfalse, not throws, when there was nothing to remove. Afalsereturn usually means the subscription was already gone — often becausepushsubscriptionchangefired and rotated it. Treat it as success for the user-facing flow but log it, and reconcile with the guidance on handling pushsubscriptionchange on the client.- The confirmation notification can itself be blocked. On some Android launchers a notification posted from within
notificationclickis coalesced with the one just dismissed if it reuses the sametag. Always use a fresh tag for the confirmation. - One-click opt-out raises measured unsubscribe rates, and that is the point. Expect the headline number to climb once you ship it, because you have replaced silent abandonment with a recorded event. Compare against the churn framing in unsubscribe rate benchmarks before anyone reads the jump as a regression.
FAQ
Can I show more than two action buttons?
You can pass more, but you cannot rely on them rendering. Notification.maxActions is the browser’s advertised ceiling and it is 2 in current Chrome, Edge and Firefox. Entries beyond that index are discarded with no error, no console warning and no fallback UI, so a third action simply does not exist for the user. Always slice the array against Notification.maxActions before passing it to showNotification(), and design the two-button layout as the target rather than the degraded case.
What happens if the user taps Unsubscribe while offline?
The fetch() to your opt-out endpoint rejects, and if you have already called subscription.unsubscribe() the endpoint is gone and the withdrawal is unrecoverable. Report first, and when the request fails, write the endpoint and reason into IndexedDB and register a Background Sync tag so the report is replayed on the next connection. Only then unsubscribe, and show the confirmation regardless — the user’s intent has been honoured locally even if the server has not heard about it yet.
Does unsubscribe() also revoke the notification permission?
No. subscription.unsubscribe() destroys the push endpoint only. Notification.permission stays granted, which is convenient: the user can re-subscribe later from your settings page without seeing the native permission dialog again. It also means your UI must not read a granted permission as evidence of an active subscription — always call pushManager.getSubscription() and check for a non-null result.
Related
- Opt-Out and Preference Centers — the settings-side surface this action complements
- GDPR-Compliant Push Unsubscribe Logging — the audit schema and retention rules for the withdrawal event
- Designing Accessible Push Notification Opt-Out Flows — focus management and live-region announcements for the in-page equivalent
- Adding Action Buttons to Web Push Notifications — icon sizing, title truncation and the full actions payload
- Handling 410 Gone Responses at Scale — what happens to the endpoint after it is torn down
Back to Opt-Out and Preference Centers