Handling Permission Denials Gracefully
This guide is part of Permission Flows & Progressive Enhancement.
When integrating native sharing, permission denials and system-level restrictions are expected production scenarios — not edge cases. Without an explicit denial strategy, a single NotAllowedError surfaces a broken share button with no recovery path. This guide covers: classifying DOMException types correctly, validating secure context before every invocation, routing blocked payloads through a progressive fallback chain, and managing re-engagement without triggering browser-level anti-harassment throttling.
What Breaks Without This Pattern
A naive implementation catches no errors and gives the user no alternative when the browser blocks navigator.share(). The share button silently fails, the payload is lost, and the user has no way to complete the action. On iOS, a NotAllowedError fires immediately if the call did not originate from a direct user gesture — a common cause of phantom failures in frameworks that debounce click events. On Android, persistent denials leave no system UI cue, so users have no indication that they need to visit browser settings.
The correct approach classifies every thrown exception, preserves the payload, and routes it to an appropriate alternative before the user notices the failure.
Prerequisites
Before writing any share code, confirm all of the following:
Browser Support Snapshot
| Browser | Minimum version | Desktop | Mobile | Known gap |
|---|---|---|---|---|
| Chrome | 61 | Yes | Yes | Must be user-gesture; no gesture = NotAllowedError |
| Safari | 12.1 | macOS 13+ only | iOS 12.2+ | Strict gesture token; async handlers invalidated |
| Firefox | 71 | No | Android only | Desktop share UI never shipped |
| Edge (Chromium) | 79 | Yes | Yes | Follows Chromium rules |
| Samsung Internet | 8.2 | — | Yes | Behaves like Chrome |
Firefox desktop returns undefined for navigator.share, so feature detection catches it before invocation. For a full cross-version breakdown including feature flags and origin-trial history, see the browser support matrix for the Web Share API.
Decision Flow: From Share Attempt to Recovery
The diagram below shows the complete path from a user tapping the share button through error classification to fallback routing.
Step-by-Step Implementation
Step 1 — Validate Secure Context and Feature Support
Gate all share UI behind a runtime capability check. Call this function once on page load to conditionally render the share trigger. A missing navigator.share or an insecure origin means the native path is permanently unavailable in this browsing session.
/**
* Returns true only when the Web Share API is available in a secure context.
* Call before rendering any share trigger UI.
* @returns {boolean}
*/
export function canUseNativeShare() {
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
return window.isSecureContext === true && typeof navigator.share === 'function';
}
const shareButton = document.getElementById('share-trigger');
if (shareButton) {
shareButton.hidden = !canUseNativeShare();
}
If canUseNativeShare() returns false, show the fallback UI immediately — no need to attempt the native path at all. The understanding secure context requirements guide covers why isSecureContext must be checked separately from navigator.share on mixed-content pages.
Step 2 — Validate the Payload Before Invocation
navigator.canShare() synchronously validates a ShareData payload against the browser’s allowed field set. An invalid combination — for example, files on a browser without file-sharing support — throws TypeError from navigator.share() before any system dialog appears. Check first to avoid masking the error as a user denial.
/**
* Validates a ShareData payload against the browser's supported field set.
* Must be called before navigator.share() to distinguish TypeError from NotAllowedError.
* @param {ShareData} payload
* @returns {{ valid: boolean; reason?: string }}
*/
export function validateSharePayload(payload) {
if (!canUseNativeShare()) {
return { valid: false, reason: 'api-unavailable' };
}
if (typeof navigator.canShare !== 'function') {
// canShare not supported — assume valid and let share() surface any error
return { valid: true };
}
const ok = navigator.canShare(payload);
return ok ? { valid: true } : { valid: false, reason: 'payload-unsupported' };
}
For a deep dive into navigator.canShare() patterns, see implementing navigator.canShare() for graceful fallbacks.
Step 3 — Invoke navigator.share() and Classify the Exception
The Web Share API throws two meaningfully different DOMException subtypes that require different responses. AbortError means the user dismissed the system sheet — no fallback needed. NotAllowedError means a policy or permission blocked the call — route to an alternative immediately.
/**
* Invokes native share and classifies the outcome.
* Must be called from a synchronous user-gesture handler.
* @param {ShareData} payload
* @returns {Promise<'success' | 'cancelled' | 'denied' | 'payload-error' | 'unexpected'>}
*/
export async function invokeNativeShare(payload) {
const validation = validateSharePayload(payload);
if (!validation.valid) {
return validation.reason === 'api-unavailable' ? 'denied' : 'payload-error';
}
try {
await navigator.share(payload);
return 'success';
} catch (error) {
if (!(error instanceof DOMException)) {
console.error('Non-DOMException from navigator.share():', error);
return 'unexpected';
}
switch (error.name) {
case 'AbortError':
// User cancelled the system sheet — no fallback, no counter increment
return 'cancelled';
case 'NotAllowedError':
console.warn('Share blocked by browser policy or missing gesture:', error.message);
return 'denied';
case 'TypeError':
// Payload contains an unsupported field or combination
console.warn('Invalid ShareData payload:', error.message);
return 'payload-error';
default:
console.error('Unexpected DOMException from navigator.share():', error.name, error.message);
return 'unexpected';
}
}
}
Step 4 — Route Denied Payloads to a Fallback
When the result is 'denied' or 'payload-error', preserve the full ShareData object and hand it off to alternative sharing. The SMS and email fallback architectures guide covers building the downstream recipients of this handoff. Trigger the fallback asynchronously to avoid blocking the gesture handler’s call stack.
/**
* Constructs a normalised ShareData object with safe defaults.
* @param {ShareData} payload
* @returns {Required<Pick<ShareData, 'title' | 'text' | 'url'>>}
*/
export function normaliseSharePayload(payload) {
return {
title: payload.title ?? document.title ?? '',
text: payload.text ?? '',
url: payload.url ?? globalThis.location?.href ?? '',
};
}
/**
* Routes a failed share payload to the fallback sharing modal.
* Safe to call on 'denied' or 'payload-error'; no-ops on 'success' or 'cancelled'.
* @param {ShareData} payload
* @param {'success' | 'cancelled' | 'denied' | 'payload-error' | 'unexpected'} result
*/
export function routeToFallback(payload, result) {
if (result === 'success' || result === 'cancelled') return;
const normalised = normaliseSharePayload(payload);
// queueMicrotask keeps this off the gesture handler's synchronous call stack
queueMicrotask(() => {
const modal = document.getElementById('share-fallback-modal');
if (!modal) {
console.warn('Fallback modal element not found in DOM.');
return;
}
modal.dataset.payload = JSON.stringify(normalised);
if (typeof modal.showModal === 'function') {
modal.showModal();
} else {
modal.style.display = 'block';
}
});
}
For QR code generation as a cross-device fallback, the same normalised.url value feeds directly into the QR encoder — no reformatting required.
Step 5 — Apply Exponential Backoff Before Re-offering the Native Dialog
After a denial, incrementing a session counter and gating the next native share attempt behind a delay prevents the browser’s anti-harassment throttling from permanently suppressing the permission prompt. The counter must reset on a successful share to avoid premature escalation.
const DENIAL_STORAGE_KEY = 'share_denial_count';
/**
* Returns the number of consecutive share denials recorded in sessionStorage.
* @returns {number}
*/
export function getDenialCount() {
return Number(sessionStorage.getItem(DENIAL_STORAGE_KEY) ?? 0);
}
/**
* Increments the denial counter. Call only on 'denied', not on 'cancelled'.
*/
export function recordDenial() {
sessionStorage.setItem(DENIAL_STORAGE_KEY, String(getDenialCount() + 1));
}
/**
* Resets the denial counter after a successful share.
*/
export function clearDenialCount() {
sessionStorage.removeItem(DENIAL_STORAGE_KEY);
}
/**
* Calculates the delay in milliseconds before offering the native share again.
* Applies jitter to prevent synchronised retries on page reload.
* @param {number} denialCount
* @param {number} [baseMs=1000]
* @param {number} [maxMs=86400000] 24 hours
* @returns {number}
*/
export function calculateBackoffDelay(denialCount = 0, baseMs = 1_000, maxMs = 86_400_000) {
const exponential = Math.min(baseMs * 2 ** denialCount, maxMs);
const jitter = Math.random() * exponential * 0.2;
return Math.round(exponential + jitter);
}
The full backoff implementation with sessionStorage persistence and re-enable logic is covered in implementing exponential backoff for permission re-prompts.
Step 6 — Wire the Pieces Together
Combine all the above into a single event handler attached to the share trigger. This is the only entry point for a share action in your application.
import {
canUseNativeShare,
invokeNativeShare,
routeToFallback,
recordDenial,
clearDenialCount,
getDenialCount,
calculateBackoffDelay,
} from './share-utils.js';
/**
* Initialises the share trigger button with full denial-handling logic.
* Call once after DOMContentLoaded.
*/
export function initShareButton() {
const button = document.getElementById('share-trigger');
if (!button) return;
if (!canUseNativeShare()) {
button.hidden = true;
return;
}
button.addEventListener('click', async () => {
const payload = {
title: document.title,
text: document.querySelector('meta[name="description"]')?.content ?? '',
url: location.href,
};
const result = await invokeNativeShare(payload);
if (result === 'success') {
clearDenialCount();
return;
}
if (result === 'denied' || result === 'payload-error' || result === 'unexpected') {
recordDenial();
routeToFallback(payload, result);
// Disable the native trigger temporarily; re-enable after backoff delay
button.disabled = true;
const delay = calculateBackoffDelay(getDenialCount());
setTimeout(() => { button.disabled = false; }, delay);
}
// 'cancelled' needs no action
});
}
Payload Validation and Error Boundary
navigator.canShare() is the canonical pre-flight check, but its behaviour varies:
- Chrome 89+ validates
filesarrays and rejects unsupported MIME types - Safari 15+ ignores the
filesfield silently rather than returningfalsefromcanShare() - Firefox Android accepts
canShare()but has a narrower set of supported MIME types
Always wrap navigator.share() in a try/catch regardless of what canShare() returns — the pre-flight is advisory, not a guarantee. A TypeError from navigator.share() with no prior canShare() failure indicates a browser-specific restriction not surfaced by the API.
Platform Gotchas
iOS / Safari (mobile): The gesture token is consumed at the point of the event listener registration. Any await before navigator.share() — even a short await fetch() — voids the token and produces NotAllowedError. Keep the navigator.share() call the very first await in the handler.
Android / Chrome: Persistent NotAllowedError on Android most commonly means the page is not the active foreground tab. This happens when share is triggered from a push notification action or a background MessageChannel message. Always confirm document.visibilityState === 'visible' before attempting a share.
Desktop Chrome / Edge: navigator.share() on desktop requires Chrome 89+ and is restricted to origins with a positive engagement score on some builds. If the share dialog never appears in development, check chrome://site-engagement/ for the localhost origin and interact with the page before testing.
Iframes: navigator.share() requires the allow="web-share" Permissions Policy attribute on the iframe element. Without it, NotAllowedError fires regardless of secure context or gesture state.
Testing and Verification
DevTools approach:
- Open DevTools → Application → Storage → Clear site data, then reload. This resets any previously set denial counters and browser engagement scores.
- In the Console, call
canUseNativeShare()and confirm it returnstruebefore testing the share button. - Set a breakpoint inside the
catchblock ofinvokeNativeShare()and inspecterror.nameto confirm classification is correct for each scenario. - Simulate
NotAllowedErrorby callingnavigator.share()directly from the Console (outside a gesture) — this reliably produces the error on Chrome and Edge.
Physical device checklist:
For setting up a local HTTPS environment where all these paths are testable, see configuring HTTPS for local development.
Common Pitfalls
- Calling
navigator.share()outside a synchronous user-gesture handler — the gesture token must be live at the point of invocation, not consumed by a priorawait - Treating
AbortErroras a denial and routing it to fallback — this trains users to expect fallback UI after simply cancelling the native sheet - Not resetting the denial counter on success — this causes premature backoff escalation after a successful share following a previous session’s denial
- Using
localStoragefor the denial counter instead ofsessionStorage— persistent cross-session denial state prevents the counter from naturally resetting on new visits - Omitting
navigator.canShare()pre-flight — without it, aTypeErrorfrom an invalid payload is indistinguishable from aNotAllowedErrorin the catch block
FAQ
How do I differentiate between a user tapping Cancel and a permanent permission denial?
Parse the caught DOMException.name. AbortError indicates user cancellation (the system sheet was dismissed); NotAllowedError indicates a system-level or policy denial. Treat them differently: cancellations require no fallback and no counter increment; denials should immediately route to alternative sharing and increment the backoff counter.
Can I programmatically reset a denied Web Share permission?
No. Browsers prevent programmatic permission resets to protect user privacy. Guide users to their browser settings (Settings → Site Settings → Permissions on Chrome Android, or Settings → Safari → Website Data on iOS). The permission resets automatically when the user clears site data.
What happens to the share payload if the native dialog fails mid-flight?
The payload remains in the JavaScript call stack as the argument passed to navigator.share(). Your catch handler has full access to it. The routeToFallback() function above receives the original ShareData object and normalises it before passing it to the fallback modal — no data is lost between the failure and the alternative UI.
Why does navigator.share() fail in an iframe even with HTTPS?
Iframes require an explicit Permissions Policy grant: <iframe allow="web-share" ...>. Without it, NotAllowedError fires regardless of secure context, user gesture, or browser version. This is enforced at the browser level and cannot be worked around in JavaScript.