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.permissionis read before anyawait, so it executes synchronously before any I/O.- The
navigator.permissions.querycall is wrapped intry/catchbecause Firefox in private browsing mode throws aTypeErroron some permission names, and Chrome forks (embedded WebViews, Electron with custom permission handlers) may reject with aNotSupportedError. - The
onchangelistener dispatches a custompushPermissionChangedevent onwindowso that multiple UI components can subscribe to state changes through a single underlyingPermissionStatusobject 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
- Open DevTools console and run
Notification.permission. Confirm the raw string value. This is the ground truth that the browser uses internally. - Check for API availability: run
'Notification' in windowand'permissions' in navigatorto confirm which code paths will execute in the current environment. - Query the Permissions API directly: run
navigator.permissions.query({ name: 'notifications' }).then(s => console.log(s.state)). Compare the result againstNotification.permission; they should agree (modulo the'prompt'/'default'vocabulary difference). - 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 confirmspushManager.permissionStateis reachable. - 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.
- Verify the
onchangelistener fires: open a second tab to the same origin, change the site permission in settings, and confirmpushPermissionChangedfires in the original tab’s console. - Test the denied branch explicitly: block notifications from address-bar site settings, reload, and confirm that your
initPushUI()renders the blocked banner rather than callingrequestPermission().
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 windowreturnsfalse, so theunsupportedbranch fires. Even within a Home Screen app, iOS may reflect an OS-level block asdenied— the user must also allow notifications under iOS Settings → Notifications, not just in Safari. Adeniedreading alone cannot distinguish a browser-level block from an OS-level block. -
Firefox private browsing:
navigator.permissions.query({ name: 'notifications' })throws aTypeErrorin Firefox private windows because the Permissions API is intentionally restricted there. Always wrap it intry/catchand fall back toNotification.permission. In private mode,Notification.permissionitself 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.permissionstill reads'granted'— the OS block is invisible to web APIs. If your notifications are not arriving butNotification.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
deniedwithout any explicit user action. The resultingdeniedstate is identical to an intentional block — there is nosourcefield inNotification.permissionto distinguish them. Keep recovery messaging neutral rather than accusatory. -
PermissionStatus.onchangefires only for cross-origin transitions: Theonchangeevent on thePermissionStatusobject fires when the permission state changes, butlocalStorageandsessionStoragewrite 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 thatstorageEventandPermissionStatus.onchangehave different tab-scope semantics.
Permission State Machine
default. Once the state is denied, no script can re-open it — show a recovery banner pointing to browser site settings instead.Related
- Silent Permission Checks and Pre-qualification — the parent section covering the full set of prompt-free inspection techniques.
- Using localStorage to Track Soft Prompt Interactions — pair the permission state read with client-side interaction tracking to build a complete pre-qualification layer.
- Re-permission and Recovery Flows — what to do after you detect
denied: recovery banners, site-settings guidance, and re-subscription. - Recovering Users Who Blocked Push Permission — the complete copy-paste playbook with per-browser instructions.
- Permission Prompt Timing Strategies — minimise the
deniedpopulation by asking at the right moment. - UI Fallbacks and Soft Prompts — in-page components for the
defaultstate that preserve the native prompt gesture.
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.