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.

Anatomy of a notification carrying an Unsubscribe action A rendered notification card with icon, title, body, origin line and two action buttons labelled View item and Unsubscribe, annotated with the matching actions array entries, the maxActions ceiling, and how the tap arrives as event.action. Rendered card Payload that produced it S Price drop on a saved item Now 42.00, down 18% since you saved it shop.example.com · now View item Unsubscribe Chrome / Edge: 2 actions Safari: actions ignored actions array 0 · action "view" · title "View item" 1 · action "unsubscribe" Notification.maxActions caps it. Extra entries are dropped with no error and no fallback. Design for two. Never three. The tap arrives as event.action notificationclick fires with the exact string "unsubscribe". A body tap sends an empty string. One event, two branches. Nothing opens on its own The handler can unsubscribe, log the withdrawal and post a confirmation without ever calling openWindow().
The Unsubscribe action button and the payload fields that produce it.

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.

Unsubscribe action sequence inside event.waitUntil() Three lanes — user notification, service worker and app server — showing the tap, the action check, the POST to the opt-out endpoint, the 204 response, subscription.unsubscribe(), the confirmation notification, and the bracket marking the single waitUntil promise chain. Notification Service worker App server tap "Unsubscribe" event.waitUntil() action === "unsubscribe" notification.close() POST /push/opt-out 204 · withdrawal logged subscription.unsubscribe() endpoint invalidated showNotification(confirm) new tag, same shade slot confirmation card, no tab One promise chain from tap to confirmation; the worker stays alive until it settles.
The full unsubscribe path, start to finish, inside a single waitUntil promise.

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.

Ordering of the opt-out report against subscription.unsubscribe() Two panels. The left panel unsubscribes first and loses the withdrawal record when the network request fails. The right panel reports the opt-out first, waits for a 2xx, then unsubscribes and confirms. Order of operations inside the handler Unsubscribe first 1 · subscription.unsubscribe() endpoint invalidated immediately 2 · POST /push/opt-out request times out on a flaky link 3 · server row still marked active no withdrawal timestamp anywhere 4 · next campaign send 410 Gone, days or weeks later Consent withdrawal never recorded. A gap in the audit trail you cannot backfill. Report first 1 · POST /push/opt-out endpoint still valid and routable 2 · await 2xx, else queue it Background Sync retries on reconnect 3 · subscription.unsubscribe() browser tears the endpoint down 4 · showNotification(confirm) the user sees the outcome Audit row written before the key dies. The 410 sweep is a safety net, not the record. The endpoint is your only join key back to the subscriber row. Spend it last.
Reporting before tearing down is the difference between an audit record and a 410 sweep.

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.

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 title verbatim 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 fetch carries cookies with credentials: '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.maxActions is not available in every worker scope at parse time. Read it inside the push handler rather than at module top level, and fall back to 2 if it is undefined. Slicing against undefined produces an empty array and your buttons vanish.
  • A denied permission does not unsubscribe anything. If the user blocks notifications in browser settings, the PushSubscription survives 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() resolves false, not throws, when there was nothing to remove. A false return usually means the subscription was already gone — often because pushsubscriptionchange fired 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 notificationclick is coalesced with the one just dismissed if it reuses the same tag. 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.

Back to Opt-Out and Preference Centers