Detecting Denied Push Permission Without Prompting

Reading the browser’s notification permission state without triggering the native dialog is the first gate every push subscription flow must clear — calling Notification.requestPermission() when permission is already denied wastes a gesture budget the browser will never honour.

Quick Answer

The browser exposes exactly three permission states for notifications: default (never asked, or reset — the prompt is allowed), granted (user approved), and denied (user or heuristic blocked — the prompt is permanently suppressed for the origin). Read Notification.permission synchronously to get the current state instantly without any UI side-effect. If you need live change events or want to compose the result with other permission types, call navigator.permissions.query({ name: 'notifications' }) instead — it returns a PermissionStatus object with an onchange handler. For push-specific state tied to a PushManager, pushManager.permissionState(options) is the third option but requires an active service worker registration and resolved VAPID options. Use Notification.permission as the primary check; the other two are supplementary.

Permission State Mechanics

Notification.permission is a synchronous property on the Notification constructor. It reads a cached value from the browser’s permission store and never opens a dialog. That cache is updated only when the user acts on the native prompt or manually changes the site setting in the browser’s permission panel.

navigator.permissions.query({ name: 'notifications' }) returns a Promise<PermissionStatus>. The resolved object carries a state property ('granted', 'denied', or 'prompt' — note that 'prompt' maps to 'default' in the Notifications API vocabulary, a deliberate inconsistency across the two APIs) and an onchange event that fires when the state transitions. This is the right API when you need reactive updates without polling. It is part of the Frontend Permission UX & Subscription Flows pattern where a single permission read seeds the entire lifecycle.

pushManager.permissionState(subscribeOptions) is the most specific of the three. It accepts the same options object as pushManager.subscribe() — including userVisibleOnly: true and your applicationServerKey — and returns 'granted', 'denied', or 'prompt'. It is useful for double-checking that push specifically is available after service worker registration, but it requires navigator.serviceWorker.ready to resolve first, making it slower than the other two.

The silent permission checks and pre-qualification pattern places these reads as early as possible — ideally inside a DOMContentLoaded handler — so that by the time any subscription UI renders, you already know which branch to take.

Code Block 1 — queryPermissionState()

This function returns a unified result object without ever triggering a native dialog. When permission is default it also attaches a PermissionStatus.onchange listener so the rest of your application can react to transitions in real time.

/**
 * Reads the current notification permission state without triggering any
 * native browser dialog. Returns a normalised result object.
 *
 * @returns {Promise<{state: 'granted'|'denied'|'default'|'unsupported', source: string}>}
 */
async function queryPermissionState() {
  // Guard: Notification API absent (older browsers, some WebViews, Node.js)
  if (!('Notification' in window)) {
    return { state: 'unsupported', source: 'Notification' };
  }

  // Synchronous read — never triggers a prompt
  const directState = Notification.permission; // 'granted' | 'denied' | 'default'

  // Fast path: already decided — no need to hit the async Permissions API
  if (directState === 'granted' || directState === 'denied') {
    return { state: directState, source: 'Notification.permission' };
  }

  // directState === 'default': use navigator.permissions for a live PermissionStatus
  if ('permissions' in navigator) {
    try {
      const status = await navigator.permissions.query({ name: 'notifications' });

      // Attach a live change listener so callers can react without polling.
      // 'prompt' in the Permissions API == 'default' in the Notifications API.
      status.onchange = () => {
        const normalised = status.state === 'prompt' ? 'default' : status.state;
        window.dispatchEvent(
          new CustomEvent('pushPermissionChanged', {
            detail: { state: normalised, source: 'navigator.permissions' }
          })
        );
      };

      // Normalise 'prompt' → 'default' to align with Notification.permission vocabulary
      const normalisedState = status.state === 'prompt' ? 'default' : status.state;
      return { state: normalisedState, source: 'navigator.permissions' };
    } catch (err) {
      // Firefox private browsing, locked-down environments, or unrecognised permission names
      console.warn('[pushPermission] navigator.permissions.query failed:', err);
    }
  }

  // Final fallback: return the synchronous value already read
  return { state: directState, source: 'Notification.permission' };
}

Key points:

  • Notification.permission is read before any await, so it executes synchronously before any I/O.
  • The navigator.permissions.query call is wrapped in try/catch because Firefox in private browsing mode throws a TypeError on some permission names, and Chrome forks (embedded WebViews, Electron with custom permission handlers) may reject with a NotSupportedError.
  • The onchange listener dispatches a custom pushPermissionChanged event on window so that multiple UI components can subscribe to state changes through a single underlying PermissionStatus object rather than each querying independently.

Code Block 2 — Gate UI on the Detected State

/**
 * Reads permission state once and renders the correct UI component.
 * Call this after DOMContentLoaded or on SPA route entry.
 */
async function initPushUI() {
  const { state } = await queryPermissionState();

  switch (state) {
    case 'denied': {
      // Never call Notification.requestPermission() here — it resolves
      // immediately as 'denied' with no dialog. Instead, surface a banner
      // explaining that the user must reset the setting in their browser.
      const banner = document.getElementById('push-blocked-banner');
      if (banner) {
        banner.hidden = false;
        banner.querySelector('[data-cta]').textContent =
          'Notifications are blocked. Re-enable in browser settings to subscribe.';
      }
      break;
    }

    case 'default': {
      // Permission is askable. Show a soft prompt widget — an in-page element
      // that the user can dismiss without burning the native prompt gesture.
      // Only escalate to Notification.requestPermission() after an explicit
      // button click inside this widget.
      const softPrompt = document.getElementById('push-soft-prompt');
      if (softPrompt) softPrompt.hidden = false;
      break;
    }

    case 'granted': {
      // Already permitted. Render a preference/management UI rather than
      // any kind of permission ask.
      const prefs = document.getElementById('push-preferences');
      if (prefs) prefs.hidden = false;
      break;
    }

    case 'unsupported':
    default:
      // Push is not available in this environment (old browser, WebView, etc.).
      // Render an alternative channel offer or silently omit push UI.
      break;
  }

  // Listen for live state changes (e.g., user grants permission in another tab,
  // or resets via site settings and returns to this tab).
  window.addEventListener('pushPermissionChanged', ({ detail }) => {
    // Re-run the gate logic when state transitions
    initPushUI();
  }, { once: true });
}

document.addEventListener('DOMContentLoaded', initPushUI);

The denied branch is the critical one: it explicitly avoids requestPermission() and shows a static informational banner instead. For the complete recovery flow — including browser-specific settings instructions — see re-permission and recovery flows and the detailed playbook for recovering users who blocked push permission.

For default state, the in-page soft prompt widget approach is described under UI fallbacks and soft prompts. Before rendering it, cross-reference permission prompt timing strategies to avoid showing the widget too early in the session.

API Comparison Table

API Triggers prompt? Returns Browser support Best use case
Notification.permission No — synchronous read 'granted' | 'denied' | 'default' All browsers that support Notifications (Chrome 22+, Firefox 22+, Safari 15+ on macOS, Safari 16.4+ on iOS as Home Screen app) Fast gate on page load; no async overhead
navigator.permissions.query({ name: 'notifications' }) No — async, read-only Promise<PermissionStatus> where state is 'granted' | 'denied' | 'prompt' Chrome 43+, Firefox 46+, Edge 79+; not supported in Safari (returns NotSupportedError or silently ignores) Reactive UI: attach onchange to avoid polling; composable with other permission types
pushManager.permissionState(options) No — async, requires SW Promise<'granted' | 'denied' | 'prompt'> Chrome 44+, Firefox 44+, Edge 17+; not available in Safari (PushManager is present but permissionState may return 'prompt' regardless of actual state) Verify push-specific permission after SW registration; most accurate for VAPID-keyed subscriptions

Diagnostic Steps

  1. Open DevTools console and run Notification.permission. Confirm the raw string value. This is the ground truth that the browser uses internally.
  2. Check for API availability: run 'Notification' in window and 'permissions' in navigator to confirm which code paths will execute in the current environment.
  3. Query the Permissions API directly: run navigator.permissions.query({ name: 'notifications' }).then(s => console.log(s.state)). Compare the result against Notification.permission; they should agree (modulo the 'prompt' / 'default' vocabulary difference).
  4. Inspect service worker readiness: run navigator.serviceWorker.ready.then(r => r.pushManager.permissionState({ userVisibleOnly: true })) in a site that has a registered service worker. This confirms pushManager.permissionState is reachable.
  5. Simulate a state change: in DevTools → Application → Service Workers, change the notification permission in the address-bar site settings, then re-run step 1. Confirm that the cached value has updated.
  6. Verify the onchange listener fires: open a second tab to the same origin, change the site permission in settings, and confirm pushPermissionChanged fires in the original tab’s console.
  7. Test the denied branch explicitly: block notifications from address-bar site settings, reload, and confirm that your initPushUI() renders the blocked banner rather than calling requestPermission().

Gotchas and Edge Cases

  • iOS Safari (Home Screen web apps only): iOS 16.4+ supports web push only for sites installed as Home Screen apps. For ordinary Safari tabs, 'Notification' in window returns false, so the unsupported branch fires. Even within a Home Screen app, iOS may reflect an OS-level block as denied — the user must also allow notifications under iOS Settings → Notifications, not just in Safari. A denied reading alone cannot distinguish a browser-level block from an OS-level block.

  • Firefox private browsing: navigator.permissions.query({ name: 'notifications' }) throws a TypeError in Firefox private windows because the Permissions API is intentionally restricted there. Always wrap it in try/catch and fall back to Notification.permission. In private mode, Notification.permission itself returns 'denied' regardless of the user’s actual site preference in normal mode.

  • OS-level block vs browser-level block: A user may have granted permission in the browser but disabled notifications for the browser application at the OS level (Windows notification settings, macOS notification preferences, iOS Settings → Notifications). From JavaScript, Notification.permission still reads 'granted' — the OS block is invisible to web APIs. If your notifications are not arriving but Notification.permission === 'granted', advise users to check OS settings in addition to browser settings.

  • Chrome’s auto-deny heuristic: After repeated dismissals of the native prompt, Chrome’s quieter notifications UI or its abuse-prevention heuristic can flip an origin to denied without any explicit user action. The resulting denied state is identical to an intentional block — there is no source field in Notification.permission to distinguish them. Keep recovery messaging neutral rather than accusatory.

  • PermissionStatus.onchange fires only for cross-origin transitions: The onchange event on the PermissionStatus object fires when the permission state changes, but localStorage and sessionStorage write events follow a different rule — they only fire in other tabs, not the one that made the change. If you are pairing the permission state with soft-prompt tracking via localStorage for soft prompt interactions, remember that storageEvent and PermissionStatus.onchange have different tab-scope semantics.

Permission State Machine

Web Push Permission State Machine Three nodes: default (centre-top), granted (bottom-left), denied (bottom-right, red). Arrows: default to granted labelled Allow; default to denied labelled Block/auto-deny. From denied, a dashed arrow labelled browser site settings reset leads back to default. A recovery flow box sits below denied. Granted leads to push subscription UI. default prompt is allowed granted subscribe / manage prefs denied prompt suppressed browser site settings user resets permission → back to default push subscription UI manage prefs / unsubscribe Allow Block / auto-deny show recovery banner reset → default

Notification.permission returns the current node label. The native prompt only fires from ‘default’.

The native permission prompt fires only from default. Once the state is denied, no script can re-open it — show a recovery banner pointing to browser site settings instead.

Back to Frontend Permission UX and Subscription Flows

FAQ

Does reading Notification.permission ever trigger the browser's permission dialog?

No. Notification.permission is a plain synchronous property read. The native permission dialog is only triggered by calling Notification.requestPermission() or pushManager.subscribe() — both require a user gesture in most browsers. Reading the property has no side-effects and is safe to call on every page load, inside service workers (via self.Notification), or during SSR hydration.

Why does navigator.permissions.query({ name: 'notifications' }) return 'prompt' instead of 'default'?

The Permissions API and the Notifications API use different vocabulary for the same concept. The Permissions API uses 'granted', 'denied', and 'prompt' (meaning “the prompt can be triggered”). The Notifications API uses 'granted', 'denied', and 'default' (meaning “the user hasn’t decided yet”). They describe identical underlying states — 'prompt' === 'default'. When combining the two APIs, normalise 'prompt' to 'default' at the point where you read PermissionStatus.state, as shown in queryPermissionState() above.

Is pushManager.permissionState() better than Notification.permission for push-specific detection?

It is more specific but slower. Notification.permission is synchronous and requires no prerequisites. pushManager.permissionState(options) requires a resolved navigator.serviceWorker.ready promise and a complete subscribeOptions object including applicationServerKey — adding latency and complexity. Use Notification.permission for the initial render gate. Use pushManager.permissionState only when you need to confirm that the specific VAPID-keyed push configuration is authorised, for example after a key rotation, or when auditing subscription health.