Recovering Users Who Blocked Push Permission

This guide shows the concrete steps to bring back users whose browser reports Notification.permission === 'denied' — detect the block, guide a site-settings reset, nudge them with the right UI, and measure how many actually return.

Quick answer

You cannot re-open the native prompt once a user has blocked notifications. The state is terminal for requestPermission(), which resolves instantly with denied and shows nothing. Recovery means three things: detect denied silently, render an in-page nudge that links to browser site settings with the correct per-browser path, then re-read permission on focus and re-subscribe the instant it returns to granted. Treat anything else — iframes, subdomains, repeated calls — as a dead end.

What you want What actually works
Re-show the OS prompt Not possible from script — guide to site settings
Detect the block Read Notification.permission (synchronous, no UI)
Reverse the block User toggles the per-site setting in their browser
Know it worked Re-check permission on focus / visibilitychange, then re-subscribe

Why the block is sticky

When a user clicks Block, or when Chrome’s repeat-dismissal heuristic auto-denies, the browser caches denied for your origin and suppresses the native dialog indefinitely. This is anti-nag behaviour shared by Chromium, Gecko, and WebKit, and there is no permitted way around it. The deny-detection mechanics — including telling an explicit block apart from an auto-block — are covered in detecting denied push permission without prompting. The full state machine and the architectural framing live in the parent re-permission and recovery flows guide.

Because the reversal happens in browser chrome you do not control, your entire recovery surface is informational: detect, explain, watch, re-subscribe.

It is worth being precise about what “blocked” actually covers, because the population is more mixed than it looks. Some of these users clicked Block deliberately and mean it. Others dismissed the dialog twice and were auto-blocked by Chrome’s heuristics without ever consciously rejecting you. A third group accepted notifications on a different device and never on this one. And a fourth — easy to forget — never actually blocked anything; their browser simply does not support push, or an OS-level toggle is suppressing delivery. Lumping all of these into a single “denied” bucket and showing the same apologetic banner to everyone is how recovery flows annoy people. The detection step below separates the genuinely-blocked from the never-supported, and your copy should stay neutral enough to fit both the deliberate blocker and the accidental one.

Detection snippet

Read the permission state directly. It never triggers UI, so run it on every page load and branch on the result.

function pushBlockStatus() {
  if (!('Notification' in window) || !('PushManager' in window)) {
    return 'unsupported';
  }
  return Notification.permission; // 'default' | 'granted' | 'denied'
}

if (pushBlockStatus() === 'denied') {
  showRecoveryNudge();   // in-page UI only — never call requestPermission() here
}

The contextual nudge UI

The nudge is a small, dismissible in-page element that appears only for blocked users and only at a relevant moment — for example after the user clicks an action that would have benefited from a notification. It explains the situation in plain language and reveals the per-browser reset steps on demand.

function showRecoveryNudge() {
  if (sessionStorage.getItem('push_nudge_seen') === '1') return;
  sessionStorage.setItem('push_nudge_seen', '1');

  const steps = resetStepsForBrowser();
  const el = document.createElement('div');
  el.setAttribute('role', 'dialog');
  el.setAttribute('aria-label', 'Re-enable notifications');
  el.className = 'push-recovery-nudge';
  el.innerHTML = `
    <p>Notifications are <strong>blocked</strong> for this site. To turn them back on:</p>
    <p class="steps">${steps}</p>
    <button type="button" data-close>Got it</button>
  `;
  el.querySelector('[data-close]').addEventListener('click', () => el.remove());
  document.body.appendChild(el);

  watchForReEnable();
}

function resetStepsForBrowser() {
  const ua = navigator.userAgent;
  if (/Firefox\//.test(ua)) {
    return 'Click the padlock in the address bar, clear the "Send Notifications" Blocked entry, then reload.';
  }
  if (/Edg\//.test(ua)) {
    return 'Click the padlock → Permissions for this site → Notifications → Allow, then reload.';
  }
  if (/Chrome\//.test(ua)) {
    return 'Click the tune/padlock icon at the left of the address bar → Site settings → Notifications → Allow, then reload.';
  }
  if (/Safari\//.test(ua)) {
    return 'Open Safari → Settings → Websites → Notifications, set this site to Allow. On iOS also check Settings → Notifications.';
  }
  return 'Open your browser\'s site settings for this page and change Notifications from Block to Allow, then reload.';
}

Numbered recovery steps

  1. Detect silently. On load, read Notification.permission. Continue only if it is denied. Never call requestPermission() for a blocked origin — it does nothing but burn a no-op.
  2. Pick the moment. Show the nudge at a contextually relevant point (a save, a follow, a price-watch action), not on first paint. Relevance is what motivates someone to dig into browser settings.
  3. Show the exact path. Sniff the user agent and render the correct per-browser instructions. Vague “check your settings” copy fails; the precise path converts.
  4. Watch for the flip. Attach visibilitychange and focus listeners. Users edit the setting in another surface and tab back; that is your signal to re-read permission.
  5. Re-subscribe immediately. When permission reads granted, call pushManager.subscribe() with your VAPID public key and POST the new endpoint to the server. The old subscription was discarded when permission was lost, so a fresh subscribe is mandatory.
function watchForReEnable() {
  const onReturn = async () => {
    if (Notification.permission !== 'granted') return;
    document.removeEventListener('visibilitychange', onReturn);
    window.removeEventListener('focus', onReturn);

    const reg = await navigator.serviceWorker.ready;
    const sub = await reg.pushManager.getSubscription()
      || await reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(window.__VAPID_PUBLIC_KEY__)
      });

    await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ subscription: sub, recovered: true })
    });
    track('push_recovered');
  };
  document.addEventListener('visibilitychange', onReturn);
  window.addEventListener('focus', onReturn);
}

Measuring recovery

Instrument three events to compute a recovery funnel: push_blocked_detected (a denied user saw the nudge), push_reset_steps_viewed (they expanded the instructions), and push_recovered (permission returned to granted and re-subscribe succeeded). Recovery rate is push_recovered / push_blocked_detected. Realistic rates are low — single-digit percentages — so judge the nudge by lift over zero, not by absolute numbers. Send the recovered: true flag with the new subscription so backend analytics can separate fresh opt-ins from recoveries.

The funnel between those three events tells you where to invest. A large gap between push_blocked_detected and push_reset_steps_viewed means your banner copy or placement is not motivating people to even look at the instructions — work on framing and timing. A large gap between push_reset_steps_viewed and push_recovered means people are reading the steps but not completing them, which usually points at instructions that are wrong for their browser or an OS-level block they cannot see. Segment the funnel by browser and platform; recovery behaviour on desktop Chrome looks nothing like recovery on iOS Safari, and a blended number hides both stories. Finally, watch the time-to-recovery distribution: most genuine recoveries happen within the same session as the nudge, so a steady trickle of “recovered days later” often indicates the browser restored a subscription on its own rather than your flow doing the work.

Be honest with yourself about ceilings. Even a well-built recovery flow recaptures only a slice of blocked users, because a denial is a real signal of disinterest for many of them. The point is not to chase a high percentage; it is to recover the subset who genuinely changed their mind, cheaply and without nagging the rest. Pair the push recovery number with how many blocked users accepted an alternative channel instead — that combined figure is the true measure of how much of the “lost” audience you brought back.

Gotchas & edge cases

  • OS-level blocks masquerade as success. A user can set the site to Allow yet still see nothing because the OS suppresses notifications (Windows Focus Assist, macOS Do Not Disturb, iOS per-app toggle). Add a line reminding users to check OS notification settings.
  • Never sniff your way into the wrong instructions. Embedded webviews and brand forks defeat user-agent detection. Always keep a generic fallback and consider linking to the browser’s own help page.
  • iOS needs the Home Screen install. Safari on iOS only delivers web push to sites added to the Home Screen as a web app. A blocked iOS user may actually be an uninstalled one; detect display-mode: standalone before promising push.
  • Re-subscribe can collide with a rotated VAPID key. If your keys changed between the original subscribe and the recovery, the new subscribe fails or produces an endpoint the server rejects. Coordinate per rotating VAPID keys without losing subscribers.
  • Do not nag. Cap the nudge to once per session (or once per week via localStorage). Users who decline twice should be routed to your opt-out and preference centers and left alone.